From 3afae4f1fb094df8fc7a49e32979b5cd135d7915 Mon Sep 17 00:00:00 2001 From: Frankie-Xu <92643488+Frankie-Xu@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:56:42 +0800 Subject: [PATCH 1/4] feat(mcp): target same-repository worktrees per tool call --- README.md | 4 + src/ingest/fs.ts | 3 + src/mcp/root.ts | 62 ++++++++++ src/mcp/tools.ts | 26 ++++- src/util/git-env.ts | 9 ++ test/mcp-server.test.ts | 42 ++++++- test/mcp-worktree-root.test.ts | 202 +++++++++++++++++++++++++++++++++ 7 files changed, 343 insertions(+), 5 deletions(-) create mode 100644 src/mcp/root.ts create mode 100644 src/util/git-env.ts create mode 100644 test/mcp-worktree-root.test.ts diff --git a/README.md b/README.md index 709a27680..91721c4d9 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,10 @@ Both configs are user-level, so they apply to **every** repo you open with Codex | `graft_repo_map` | nothing | A first look at an unfamiliar repo: directory clusters, hubs, hotspots. | | `graft_check_freshness` | nothing | Whether the local graph has drifted from the code. | +Every tool accepts an optional `root` to query another checkout, for example `graft_find_all` with `{"pattern":"readList", "root":"/tmp/review"}`. The path may name the checkout or one of its subdirectories; relative paths resolve from the server's root. Explicit roots must share the server checkout's Git common directory, so linked worktrees are accepted while independent clones, submodules and unrelated repositories are refused. Omitting `root` keeps the server's existing root selection, including workspace queries. + +Each checkout uses its own graph and in-process cache. A fresh worktree can copy its main checkout's graph and refresh it before answering, just like `graft mcp /tmp/review`. A server started with `--dir` keeps its original source root and graph directory, and refuses to switch to another checkout. Tool results end with the resolved root and its current full `HEAD` commit (`unavailable` outside Git or before the first commit). HEAD identifies the checkout; queries still include uncommitted source edits, and refresh warnings still apply. A rejected root returns a soft tool error before graph access. + Register it by hand if your agent needs it explicit: ```json diff --git a/src/ingest/fs.ts b/src/ingest/fs.ts index 1f24567dd..7469cd805 100644 --- a/src/ingest/fs.ts +++ b/src/ingest/fs.ts @@ -2,6 +2,7 @@ * Filesystem walking used by `init`/`check` to enumerate a repo's source files. */ import { spawnSync } from "node:child_process"; +import { gitEnvForDirectory } from "../util/git-env.js"; import { existsSync, lstatSync, readdirSync, realpathSync, statSync } from "node:fs"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; @@ -191,6 +192,7 @@ function gitVisibleFiles( ["ls-files", "-t", "--stage", "--cached", "--others", "--exclude-standard", "-z", "--"], { cwd: root, + env: gitEnvForDirectory(), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024, @@ -295,6 +297,7 @@ function gitVisibleFilesShallow(root: string, includes?: ReadonlySet): s ["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--"], { cwd: root, + env: gitEnvForDirectory(), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024, diff --git a/src/mcp/root.ts b/src/mcp/root.ts new file mode 100644 index 000000000..36d330650 --- /dev/null +++ b/src/mcp/root.ts @@ -0,0 +1,62 @@ +/** Per-call checkout selection for a server shared by agents in linked worktrees. */ +import { execFileSync } from 'node:child_process'; +import { realpathSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { gitEnvForDirectory } from '../util/git-env.js'; + +/** Git must inspect the requested directory, even when the host inherited a + * repository override (for example from a Git hook). */ +function git(root: string, ...args: string[]): string { + return execFileSync('git', ['-C', root, 'rev-parse', ...args], { + env: gitEnvForDirectory(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 2000, maxBuffer: 64 * 1024, + }).trim(); +} + +function checkout(root: string): { root: string; common: string } { + const top = realpathSync(git(root, '--show-toplevel')); + const common = realpathSync(git(root, '--path-format=absolute', '--git-common-dir')); + return { root: top, common }; +} + +/** Missing `root` preserves the server's existing selection, including workspace + * and non-Git roots. Explicit paths choose a checkout, never an ancestor graph. + * Repository identity is the on-disk common Git directory: equal remote URLs + * do not grant access to an independent clone or submodule. */ +export function resolveToolRoot(serverRoot: string, requested: unknown, dirOverride?: string): string { + if (requested === undefined) return serverRoot; + if (typeof requested !== 'string' || !requested.trim()) { + throw new Error('root must be a non-empty checkout path'); + } + const path = resolve(serverRoot, requested); + let target: ReturnType; + let server: ReturnType; + try { + if (!statSync(path).isDirectory()) throw new Error('not a directory'); + target = checkout(path); + server = checkout(serverRoot); + } catch { + throw new Error(`cannot select root ${path} — root and server must be existing Git checkouts of the same repository`); + } + if (target.common !== server.common) { + throw new Error(`refusing root ${target.root} — it is not a checkout of the server's Git repository (${server.root})`); + } + // A --dir graph is pinned to one source tree. Sharing it between worktrees + // would overwrite the parent's graph during the ordinary refresh step. + if (dirOverride !== undefined) { + if (target.root !== server.root) { + throw new Error('cannot select another root while --dir pins the graph directory — start a server without --dir to query another worktree'); + } + // Preserve the source scope too: an explicit server root may be a repo + // subdirectory with a custom graph built only from that subdirectory. + return serverRoot; + } + return target.root; +} + +/** Read each time: a long-lived server must notice checkout/commit changes. + * HEAD identifies the checkout, not a claim that uncommitted files are absent. */ +export function rootNote(root: string): string { + let head = 'unavailable'; + try { head = git(root, '--verify', 'HEAD'); } catch { /* non-Git/unborn checkout */ } + return `[graft] root: ${resolve(root)} · HEAD: ${head}`; +} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 1167ae307..fea71f7d0 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -26,6 +26,7 @@ import { } from '../graph/workspace.js'; import type { NodeV1 } from '../graph/types.js'; import { canonicalToolName } from './tool-names.js'; +import { resolveToolRoot, rootNote } from './root.js'; export interface ToolDef { name: string; @@ -33,6 +34,11 @@ export interface ToolDef { inputSchema: object; } +const ROOT_ARGUMENT = { + type: 'string', + description: 'checkout path in the server’s Git repository (absolute or relative to the server root); defaults to the server root; cannot switch checkout with --dir', +}; + const NO_GRAPH = 'no graph found — run `graft build` first'; function unknownSymbolText(query: string): string { @@ -47,6 +53,7 @@ export const TOOLS: ToolDef[] = [ inputSchema: { type: 'object', properties: { + root: ROOT_ARGUMENT, query: { type: 'string', description: 'what you want to understand, in plain words' }, limit: { type: 'number', description: 'max results (default 5)' }, full: { @@ -68,6 +75,7 @@ export const TOOLS: ToolDef[] = [ inputSchema: { type: 'object', properties: { + root: ROOT_ARGUMENT, file: { type: 'string', description: 'repo-relative path (or unique basename) of the file' }, }, required: ['file'], @@ -76,7 +84,7 @@ export const TOOLS: ToolDef[] = [ { name: 'graft_check_freshness', description: 'Report whether the committed graph is in sync with the code (drift check).', - inputSchema: { type: 'object', properties: {} }, + inputSchema: { type: 'object', properties: { root: ROOT_ARGUMENT } }, }, { name: 'graft_trace_calls', @@ -85,6 +93,7 @@ export const TOOLS: ToolDef[] = [ inputSchema: { type: 'object', properties: { + root: ROOT_ARGUMENT, symbol: { type: 'string', description: 'bare name, qualified (Class.method), or package-qualified (pkg.Fn); a file path also works' }, direction: { type: 'string', @@ -104,6 +113,7 @@ export const TOOLS: ToolDef[] = [ inputSchema: { type: 'object', properties: { + root: ROOT_ARGUMENT, pattern: { type: 'string', description: 'regex pattern (or literal string with fixed: true)' }, in: { type: 'string', description: 'narrow to files at or under this repo-relative path prefix, e.g. server/src' }, ignore_case: { type: 'boolean', description: 'case-insensitive match' }, @@ -119,6 +129,7 @@ export const TOOLS: ToolDef[] = [ inputSchema: { type: 'object', properties: { + root: ROOT_ARGUMENT, max_dirs: { type: 'number', description: 'max directory entries shown, rest counted into dropped (default 16)' }, }, }, @@ -219,7 +230,10 @@ export async function callTool( args: Record, dirOverride?: string, ): Promise<{ text: string; isError: boolean }> { + let answeredRoot: string | undefined; try { + root = resolveToolRoot(root, args.root, dirOverride); + answeredRoot = root; const name = canonicalToolName(requestedName); const ws = readWorkspace(root, dirOverride); // Freshness first: an answer that cites file:line has to be about the code as @@ -227,7 +241,6 @@ export async function callTool( // this agent). ~3ms when nothing moved; a structural, $0 rebuild when it did. // Same reason as the CLI's `noteQuery`: price this session's tokens once, // here, so the formatters downstream can put a dollar figure in the nudge. - setInputRate(sessionInputRate(root)); let note: string | null = null; if (!NO_REFRESH_TOOLS.has(name)) { const r = ws @@ -235,11 +248,16 @@ export async function callTool( : await ensureFreshGraph(root, { contextDir: dirOverride }); note = refreshNote(r); } + // Pricing is process-global. Set it after the refresh await, directly + // before synchronous query formatting, so concurrent roots do not cross-talk. + setInputRate(sessionInputRate(root)); const fed = ws ? await callWorkspaceTool(root, dirOverride, name, args) : null; const res = fed ?? (await callSingleTool(root, name, args, dirOverride)); - return note ? { ...res, text: `${note}\n${res.text}` } : res; + const text = note ? `${note}\n${res.text}` : res.text; + return { ...res, text: `${text}\n${rootNote(root)}` }; } catch (err) { - return { text: err instanceof Error ? err.message : String(err), isError: true }; + const text = err instanceof Error ? err.message : String(err); + return { text: answeredRoot ? `${text}\n${rootNote(answeredRoot)}` : text, isError: true }; } } diff --git a/src/util/git-env.ts b/src/util/git-env.ts new file mode 100644 index 000000000..a92923120 --- /dev/null +++ b/src/util/git-env.ts @@ -0,0 +1,9 @@ +/** Git probes with an explicit cwd must inspect that directory, even when an + * agent host inherited repository overrides (for example from a Git hook). */ +export function gitEnvForDirectory(): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES']) { + delete env[key]; + } + return env; +} diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts index f0610e034..1c3dfef5f 100644 --- a/test/mcp-server.test.ts +++ b/test/mcp-server.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync, spawn } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { once } from 'node:events'; @@ -153,3 +153,43 @@ test('unknown method returns -32601', async () => { const rs = await rpc([{ jsonrpc: '2.0', id: 9, method: 'resources/list' }], dir, 1); assert.equal(rs[0].error.code, -32601); }); + + +test('one MCP server answers concurrent calls from distinct same-repository checkouts', async (t) => { + const base = realpathSync(mkdtempSync(join(tmpdir(), 'graft-mcpsrv-roots-'))); + t.after(() => rmSync(base, { recursive: true, force: true })); + const main = join(base, 'main'); mkdirSync(main); + const git = (...args: string[]): string => execFileSync('git', ['-c', 'commit.gpgsign=false', ...args], { + cwd: main, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', + GIT_AUTHOR_NAME: 'graft test', GIT_AUTHOR_EMAIL: 'test@example.invalid', + GIT_COMMITTER_NAME: 'graft test', GIT_COMMITTER_EMAIL: 'test@example.invalid' }, + }).trim(); + git('init', '-b', 'main'); + writeFileSync(join(main, '.gitignore'), 'graft/\n'); + writeFileSync(join(main, 'x.ts'), "export function version() { return 'older-source'; }\n"); + git('add', '-A'); git('commit', '-m', 'older'); + const olderHead = git('rev-parse', 'HEAD'); + const review = join(base, 'review'); git('worktree', 'add', '--detach', review, olderHead); + writeFileSync(join(main, 'x.ts'), "export function version() { return 'current-source'; }\n"); + git('add', 'x.ts'); git('commit', '-m', 'current'); + const mainHead = git('rev-parse', 'HEAD'); + await buildGraph(main); + const rs = await rpc([ + { jsonrpc: '2.0', id: 1, method: 'tools/list' }, + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'graft_find_all', arguments: { pattern: 'source' } } }, + { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'graft_find_all', arguments: { pattern: 'source', root: review } } }, + ], main, 3); + assert.equal(rs.length, 3); + for (const tool of rs.find((r) => r.id === 1).result.tools) assert.equal(tool.inputSchema.properties.root.type, 'string'); + for (const [id, marker, absent, root, head] of [ + [2, 'current-source', 'older-source', main, mainHead], + [3, 'older-source', 'current-source', review, olderHead], + ] as const) { + const answer = rs.find((r) => r.id === id).result; + assert.equal(answer.isError, false); + const text = answer.content[0].text; + assert.ok(text.includes(marker), text); assert.ok(!text.includes(absent), text); + assert.ok(text.endsWith(`[graft] root: ${root} · HEAD: ${head}`), text); + } +}); diff --git a/test/mcp-worktree-root.test.ts b/test/mcp-worktree-root.test.ts new file mode 100644 index 000000000..94226eee2 --- /dev/null +++ b/test/mcp-worktree-root.test.ts @@ -0,0 +1,202 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import { buildGraph } from '../src/graph/build.js'; +import { __parseCount, __resetParseCounts, loadGraphCached } from '../src/graph/load.js'; +import { TOOLS, callTool } from '../src/mcp/tools.js'; + +const gitEnv = { + ...process.env, + GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null', + GIT_AUTHOR_NAME: 'graft test', GIT_AUTHOR_EMAIL: 'test@example.invalid', + GIT_COMMITTER_NAME: 'graft test', GIT_COMMITTER_EMAIL: 'test@example.invalid', +}; +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', ['-c', 'commit.gpgsign=false', ...args], { + cwd, env: gitEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} +const source = (version: string, caller: string): string => + `export function version(): string {\n return '${version}';\n}\nexport function ${caller}(): string {\n return version();\n}\nexport function entry(): string {\n return ${caller}();\n}\n`; +async function fixture(t: { after: (fn: () => void) => void }) { + const base = realpathSync(mkdtempSync(join(tmpdir(), 'graft-mcp-roots-'))); + t.after(() => rmSync(base, { recursive: true, force: true })); + const main = join(base, 'main'); + mkdirSync(join(main, 'src'), { recursive: true }); + git(main, 'init', '-b', 'main'); + writeFileSync(join(main, '.gitignore'), 'graft/\n'); + writeFileSync(join(main, 'src', 'version.ts'), source('old-checkout', 'reviewOnly')); + git(main, 'add', '-A'); git(main, 'commit', '-m', 'old version'); + const olderHead = git(main, 'rev-parse', 'HEAD'); + const review = join(main, '.claude', 'worktrees', 'review'); + git(main, 'worktree', 'add', '--detach', review, olderHead); + writeFileSync(join(main, 'src', 'version.ts'), source('current-checkout', 'parentOnly')); + git(main, 'add', 'src/version.ts'); git(main, 'commit', '-m', 'current version'); + const mainHead = git(main, 'rev-parse', 'HEAD'); + await buildGraph(main); + assert.equal(existsSync(join(review, 'graft')), false); + return { base, main, review, olderHead, mainHead }; +} +function body(text: string): string { return text.split('\n[graft] root:')[0]; } +function provenance(text: string, root: string, head: string): void { + assert.ok(text.endsWith(`[graft] root: ${root} · HEAD: ${head}`), text); +} + +test('every schema accepts an optional root', () => { + for (const tool of TOOLS) { + const schema = tool.inputSchema as { properties: Record; required?: string[] }; + assert.equal(schema.properties.root?.type, 'string', tool.name); + assert.ok(!schema.required?.includes('root')); + } +}); + +test('two checkout graphs, source spans and HEADs stay isolated across all tools and cached calls', async (t) => { + const f = await fixture(t); + const defaults = await callTool(f.main, 'graft_find_code', { query: 'version', full: true }); + assert.equal(defaults.isError, false); + assert.match(body(defaults.text), /current-checkout/); + provenance(defaults.text, f.main, f.mainHead); + const selected = await callTool(f.main, 'graft_find_code', { query: 'version', full: true, root: f.review }); + assert.equal(selected.isError, false); + assert.match(body(selected.text), /old-checkout/); + assert.doesNotMatch(body(selected.text), /current-checkout|parentOnly/); + provenance(selected.text, f.review, f.olderHead); + assert.ok(existsSync(join(f.review, 'graft', '.graph', 'wiring.json')), 'target seeds then refreshes'); + + for (const [name, args, expected, absent] of [ + ['graft_find_all', { pattern: 'checkout' }, 'old-checkout', 'current-checkout'], + ['graft_file_api', { file: 'src/version.ts' }, 'reviewOnly', 'parentOnly'], + ['graft_trace_calls', { symbol: 'version' }, 'reviewOnly', 'parentOnly'], + ['graft_repo_map', {}, 'reviewOnly', 'parentOnly'], + ['graft_check_freshness', {}, 'graph check: OK', 'graph check: stale'], + ] as const) { + const answer = await callTool(f.main, name, { ...args, root: f.review }); + assert.equal(answer.isError, false, answer.text); + assert.ok(body(answer.text).includes(expected), answer.text); + assert.ok(!body(answer.text).includes(absent), answer.text); + provenance(answer.text, f.review, f.olderHead); + } + const alias = await callTool(f.main, 'graft_grep', { pattern: 'checkout', root: f.review }); + assert.match(body(alias.text), /old-checkout/); + provenance(alias.text, f.review, f.olderHead); + + loadGraphCached(join(f.main, 'graft')); loadGraphCached(join(f.review, 'graft')); + __resetParseCounts(); + for (let i = 0; i < 3; i++) { + const parent = await callTool(f.main, 'graft_trace_calls', { symbol: 'version' }); + const target = await callTool(f.main, 'graft_trace_calls', { symbol: 'version', root: f.review }); + assert.match(body(parent.text), /parentOnly/); assert.doesNotMatch(body(parent.text), /reviewOnly/); + assert.match(body(target.text), /reviewOnly/); assert.doesNotMatch(body(target.text), /parentOnly/); + provenance(parent.text, f.main, f.mainHead); provenance(target.text, f.review, f.olderHead); + const parentAsk = await callTool(f.main, 'graft_find_code', { query: 'version', full: true }); + const targetAsk = await callTool(f.main, 'graft_find_code', { query: 'version', full: true, root: f.review }); + assert.match(body(parentAsk.text), /current-checkout/); assert.match(body(targetAsk.text), /old-checkout/); + } + assert.equal(__parseCount.graph, 0, 'each root retains its graph cache'); + assert.equal(__parseCount.askIndex, 0, 'each root retains its ask-index cache'); + + const mainGraphBefore = readFileSync(join(f.main, 'graft', '.graph', 'wiring.json'), 'utf8'); + writeFileSync(join(f.review, 'src', 'version.ts'), source('updated-review', 'reviewUpdated')); + git(f.review, 'add', 'src/version.ts'); git(f.review, 'commit', '-m', 'advance review HEAD'); + const newHead = git(f.review, 'rev-parse', 'HEAD'); + const reviewGraphBefore = readFileSync(join(f.review, 'graft', '.graph', 'wiring.json'), 'utf8'); + const drift = await callTool(f.main, 'graft_check_freshness', { root: f.review }); + assert.match(body(drift.text), /changed \(|removed \(|added \(/); + assert.equal(readFileSync(join(f.review, 'graft', '.graph', 'wiring.json'), 'utf8'), reviewGraphBefore, 'freshness reports drift without repairing it'); + provenance(drift.text, f.review, newHead); + const updated = await callTool(f.main, 'graft_find_code', { query: 'version', full: true, root: f.review }); + assert.match(body(updated.text), /updated-review/); assert.doesNotMatch(body(updated.text), /old-checkout/); + provenance(updated.text, f.review, newHead); + assert.equal(readFileSync(join(f.main, 'graft', '.graph', 'wiring.json'), 'utf8'), mainGraphBefore); + const parent = await callTool(f.main, 'graft_find_code', { query: 'version', full: true }); + assert.match(body(parent.text), /current-checkout/); provenance(parent.text, f.main, f.mainHead); +}); + +test('relative roots, checkout subdirectories and symlink aliases resolve within the same repository', async (t) => { + const f = await fixture(t); + for (const root of [relative(f.main, f.review), join(f.review, 'src')]) { + const answer = await callTool(f.main, 'graft_find_all', { pattern: 'checkout', root }); + assert.equal(answer.isError, false, answer.text); assert.match(body(answer.text), /old-checkout/); + provenance(answer.text, f.review, f.olderHead); + } + const alias = join(f.base, 'review-alias'); + symlinkSync(f.review, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const answer = await callTool(f.main, 'graft_find_all', { pattern: 'checkout', root: alias }); + assert.equal(answer.isError, false, answer.text); provenance(answer.text, f.review, f.olderHead); +}); + +test('invalid, foreign, cloned and submodule roots are refused before any graph write', async (t) => { + const f = await fixture(t); + const plain = join(f.base, 'plain'); mkdirSync(plain); + const foreign = join(f.base, 'foreign'); mkdirSync(foreign); git(foreign, 'init', '-b', 'main'); + const clone = join(f.base, 'clone'); git(f.base, 'clone', f.main, clone); + const submodule = join(f.main, 'module'); + git(f.main, '-c', 'protocol.file.allow=always', 'submodule', 'add', clone, 'module'); + const foreignAlias = join(f.base, 'foreign-alias'); + symlinkSync(foreign, foreignAlias, process.platform === 'win32' ? 'junction' : 'dir'); + const before = readFileSync(join(f.main, 'graft', '.graph', 'wiring.json'), 'utf8'); + for (const root of ['', 42, null, join(f.base, 'missing'), plain, foreign, clone, submodule, foreignAlias]) { + const answer = await callTool(f.main, 'graft_repo_map', { root }); + assert.equal(answer.isError, true, `root=${root}: ${answer.text}`); + assert.match(answer.text, /root/i); + } + assert.equal(readFileSync(join(f.main, 'graft', '.graph', 'wiring.json'), 'utf8'), before); + for (const root of [plain, foreign, clone, submodule]) assert.equal(existsSync(join(root, 'graft')), false); +}); + +test('a pinned --dir never follows root to another checkout', async (t) => { + const f = await fixture(t); + const graphDir = join(f.main, 'custom-graph'); await buildGraph(f.main, { contextDir: graphDir }); + const before = readFileSync(join(graphDir, '.graph', 'wiring.json'), 'utf8'); + const denied = await callTool(f.main, 'graft_find_all', { pattern: 'checkout', root: f.review }, graphDir); + assert.equal(denied.isError, true); assert.match(denied.text, /--dir/); + assert.equal(readFileSync(join(graphDir, '.graph', 'wiring.json'), 'utf8'), before); + assert.equal(existsSync(join(f.review, 'graft')), false); + const own = await callTool(f.main, 'graft_find_all', { pattern: 'checkout', root: f.main }, graphDir); + assert.equal(own.isError, false, own.text); assert.match(body(own.text), /current-checkout/); + provenance(own.text, f.main, f.mainHead); + const scope = join(f.main, 'src'); + const scopedGraph = join(f.base, 'scoped-graph'); await buildGraph(scope, { contextDir: scopedGraph }); + const scoped = await callTool(scope, 'graft_find_all', { pattern: 'checkout', root: f.main }, scopedGraph); + assert.equal(scoped.isError, false, scoped.text); assert.match(body(scoped.text), /current-checkout/); + provenance(scoped.text, scope, f.mainHead); +}); + +test('non-Git default queries keep working and report unavailable HEAD', async (t) => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'graft-mcp-no-git-'))); + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(join(dir, 'x.ts'), 'export function standalone() { return 1; }\n'); + await buildGraph(dir); + const answer = await callTool(dir, 'graft_file_api', { file: 'x.ts' }); + assert.equal(answer.isError, false, answer.text); assert.match(body(answer.text), /standalone/); + provenance(answer.text, dir, 'unavailable'); +}); + +test('inherited Git root overrides cannot change checkout identity or reported HEAD', async (t) => { + const f = await fixture(t); + writeFileSync(join(f.review, 'src', 'target-only.ts'), 'export function targetOnly() { return 42; }\n'); + git(f.review, 'add', 'src/target-only.ts'); git(f.review, 'commit', '-m', 'target-only file'); + const targetHead = git(f.review, 'rev-parse', 'HEAD'); + const keys = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR']; + const previous = keys.map((key) => process.env[key]); + t.after(() => keys.forEach((key, i) => { + if (previous[i] === undefined) delete process.env[key]; + else process.env[key] = previous[i]; + })); + process.env.GIT_DIR = join(f.main, '.git'); + process.env.GIT_WORK_TREE = f.main; + process.env.GIT_COMMON_DIR = join(f.main, '.git'); + const answer = await callTool(f.main, 'graft_find_all', { pattern: 'checkout', root: f.review }); + assert.equal(answer.isError, false, answer.text); assert.match(body(answer.text), /old-checkout/); + provenance(answer.text, f.review, targetHead); + const unique = await callTool(f.main, 'graft_trace_calls', { symbol: 'targetOnly', root: f.review }); + assert.equal(unique.isError, false, unique.text); assert.match(body(unique.text), /targetOnly/); + provenance(unique.text, f.review, targetHead); + const foreign = join(f.base, 'foreign'); mkdirSync(foreign); git(foreign, 'init', '-b', 'main'); + const denied = await callTool(f.main, 'graft_repo_map', { root: foreign }); + assert.equal(denied.isError, true); assert.match(denied.text, /not a checkout/); + assert.equal(existsSync(join(foreign, 'graft')), false); +}); From 5b327dfbd42b7d6901aeae3093a075f0e5f2284a Mon Sep 17 00:00:00 2001 From: Frankie-Xu <92643488+Frankie-Xu@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:06:28 +0800 Subject: [PATCH 2/4] test(mcp): compare canonical roots in provenance assertions --- test/mcp-server.test.ts | 3 ++- test/mcp-worktree-root.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts index 1c3dfef5f..f9efc6324 100644 --- a/test/mcp-server.test.ts +++ b/test/mcp-server.test.ts @@ -190,6 +190,7 @@ test('one MCP server answers concurrent calls from distinct same-repository chec assert.equal(answer.isError, false); const text = answer.content[0].text; assert.ok(text.includes(marker), text); assert.ok(!text.includes(absent), text); - assert.ok(text.endsWith(`[graft] root: ${root} · HEAD: ${head}`), text); + const footer = text.slice(text.lastIndexOf('\n[graft] root:') + 1); + assert.equal(footer, `[graft] root: ${realpathSync(root)} · HEAD: ${head}`); } }); diff --git a/test/mcp-worktree-root.test.ts b/test/mcp-worktree-root.test.ts index 94226eee2..cd1c6f4e3 100644 --- a/test/mcp-worktree-root.test.ts +++ b/test/mcp-worktree-root.test.ts @@ -42,7 +42,8 @@ async function fixture(t: { after: (fn: () => void) => void }) { } function body(text: string): string { return text.split('\n[graft] root:')[0]; } function provenance(text: string, root: string, head: string): void { - assert.ok(text.endsWith(`[graft] root: ${root} · HEAD: ${head}`), text); + const footer = text.slice(text.lastIndexOf('\n[graft] root:') + 1); + assert.equal(footer, `[graft] root: ${realpathSync(root)} · HEAD: ${head}`); } test('every schema accepts an optional root', () => { From fbc9440d9562342e93ed2866292bd39883e19e63 Mon Sep 17 00:00:00 2001 From: Frankie-Xu <92643488+Frankie-Xu@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:03:24 +0800 Subject: [PATCH 3/4] ci: keep CodeQL initialization and analysis versions aligned --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7760095b2..888512d27 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -26,7 +26,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: languages: javascript-typescript From 8d906bbb338427948a53b75193004d7012b65843 Mon Sep 17 00:00:00 2001 From: Frankie-Xu <92643488+Frankie-Xu@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:17:27 +0800 Subject: [PATCH 4/4] test(mcp): expand Windows short paths in checkout fixtures --- test/mcp-server.test.ts | 4 ++-- test/mcp-worktree-root.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts index f9efc6324..7e34b2e07 100644 --- a/test/mcp-server.test.ts +++ b/test/mcp-server.test.ts @@ -156,7 +156,7 @@ test('unknown method returns -32601', async () => { test('one MCP server answers concurrent calls from distinct same-repository checkouts', async (t) => { - const base = realpathSync(mkdtempSync(join(tmpdir(), 'graft-mcpsrv-roots-'))); + const base = realpathSync.native(mkdtempSync(join(tmpdir(), 'graft-mcpsrv-roots-'))); t.after(() => rmSync(base, { recursive: true, force: true })); const main = join(base, 'main'); mkdirSync(main); const git = (...args: string[]): string => execFileSync('git', ['-c', 'commit.gpgsign=false', ...args], { @@ -191,6 +191,6 @@ test('one MCP server answers concurrent calls from distinct same-repository chec const text = answer.content[0].text; assert.ok(text.includes(marker), text); assert.ok(!text.includes(absent), text); const footer = text.slice(text.lastIndexOf('\n[graft] root:') + 1); - assert.equal(footer, `[graft] root: ${realpathSync(root)} · HEAD: ${head}`); + assert.equal(footer, `[graft] root: ${realpathSync.native(root)} · HEAD: ${head}`); } }); diff --git a/test/mcp-worktree-root.test.ts b/test/mcp-worktree-root.test.ts index cd1c6f4e3..e319a2c03 100644 --- a/test/mcp-worktree-root.test.ts +++ b/test/mcp-worktree-root.test.ts @@ -22,7 +22,7 @@ function git(cwd: string, ...args: string[]): string { const source = (version: string, caller: string): string => `export function version(): string {\n return '${version}';\n}\nexport function ${caller}(): string {\n return version();\n}\nexport function entry(): string {\n return ${caller}();\n}\n`; async function fixture(t: { after: (fn: () => void) => void }) { - const base = realpathSync(mkdtempSync(join(tmpdir(), 'graft-mcp-roots-'))); + const base = realpathSync.native(mkdtempSync(join(tmpdir(), 'graft-mcp-roots-'))); t.after(() => rmSync(base, { recursive: true, force: true })); const main = join(base, 'main'); mkdirSync(join(main, 'src'), { recursive: true }); @@ -43,7 +43,7 @@ async function fixture(t: { after: (fn: () => void) => void }) { function body(text: string): string { return text.split('\n[graft] root:')[0]; } function provenance(text: string, root: string, head: string): void { const footer = text.slice(text.lastIndexOf('\n[graft] root:') + 1); - assert.equal(footer, `[graft] root: ${realpathSync(root)} · HEAD: ${head}`); + assert.equal(footer, `[graft] root: ${realpathSync.native(root)} · HEAD: ${head}`); } test('every schema accepts an optional root', () => { @@ -167,7 +167,7 @@ test('a pinned --dir never follows root to another checkout', async (t) => { }); test('non-Git default queries keep working and report unavailable HEAD', async (t) => { - const dir = realpathSync(mkdtempSync(join(tmpdir(), 'graft-mcp-no-git-'))); + const dir = realpathSync.native(mkdtempSync(join(tmpdir(), 'graft-mcp-no-git-'))); t.after(() => rmSync(dir, { recursive: true, force: true })); writeFileSync(join(dir, 'x.ts'), 'export function standalone() { return 1; }\n'); await buildGraph(dir);