diff --git a/README.md b/README.md index 68a6a0610..ad50dc93c 100644 --- a/README.md +++ b/README.md @@ -596,6 +596,10 @@ These apply to **every** repo you open with that agent, not just this one. The p | `trail_repo_map` | nothing | A first look at an unfamiliar repo: directory clusters, hubs, hotspots. | | `trail_check_freshness` | nothing | Whether the local graph has drifted from the code. | +Every tool accepts an optional `root` to query another checkout, for example `trail_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 `trail 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. + The same server still answers the old `graft_*` tool names. 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..4880ab81e --- /dev/null +++ b/src/mcp/root.ts @@ -0,0 +1,63 @@ +/** 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'; +import { tag } from '../brand.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 `${tag()} root: ${resolve(root)} · HEAD: ${head}`; +} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 9543428fb..f2f418e10 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'; import { cmd } from '../brand.js'; export interface ToolDef { @@ -34,6 +35,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 noGraph = (): string => `no graph found — run \`${cmd('graft build')}\` first`; function unknownSymbolText(query: string): string { @@ -48,6 +54,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: { @@ -69,6 +76,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'], @@ -77,7 +85,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', @@ -86,6 +94,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', @@ -105,6 +114,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' }, @@ -120,6 +130,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)' }, }, }, @@ -220,7 +231,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 @@ -228,7 +242,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 @@ -236,11 +249,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..7e34b2e07 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,44 @@ 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.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], { + 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); + const footer = text.slice(text.lastIndexOf('\n[graft] root:') + 1); + 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 new file mode 100644 index 000000000..b27c4a0b4 --- /dev/null +++ b/test/mcp-worktree-root.test.ts @@ -0,0 +1,230 @@ +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.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 }); + 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 { + const footer = text.slice(text.lastIndexOf('\n[graft] root:') + 1); + assert.equal(footer, `[graft] root: ${realpathSync.native(root)} · HEAD: ${head}`); +} + +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')); + } +}); + +for (const brand of ['graft', 'trail'] as const) { + test(`${brand} and legacy aliases select the same worktree with branded provenance`, async (t) => { + const previous = process.env.TRAIL_INVOKED_AS; + process.env.TRAIL_INVOKED_AS = brand; + t.after(() => { + if (previous === undefined) delete process.env.TRAIL_INVOKED_AS; + else process.env.TRAIL_INVOKED_AS = previous; + }); + const f = await fixture(t); + for (const prefix of ['graft', 'trail']) { + const answer = await callTool(f.main, `${prefix}_find_all`, { pattern: 'checkout', root: f.review }); + assert.equal(answer.isError, false, answer.text); + assert.match(answer.text, /old-checkout/); + assert.doesNotMatch(answer.text, /current-checkout/); + assert.ok(answer.text.endsWith(`[${brand}] root: ${f.review} · HEAD: ${f.olderHead}`)); + } + const missing = join(f.base, 'empty'); + mkdirSync(missing); + const rejected = await callTool(f.main, `${brand}_find_all`, { pattern: 'checkout', root: missing }); + assert.equal(rejected.isError, true); + assert.equal(existsSync(join(missing, 'graft')), false, 'invalid roots never create a graph'); + const noGraph = await callTool(missing, `${brand}_trace_calls`, { symbol: 'missing' }); + assert.equal(noGraph.isError, true); + assert.ok(noGraph.text.includes(`${brand} build`), noGraph.text); + }); +} + +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.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); + 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); +});