瀏覽代碼

feat(mcp): detect borrowed git worktree index and surface on read tools (#312)

When a worktree is nested inside the main checkout (e.g. agent tools that place
worktrees under .claude/worktrees/<name>/), the nearest-.codegraph walk resolves
UP to the main checkout's index and queries silently return that tree's code —
usually a different branch. Symbols changed only in the worktree are invisible,
and nothing tells the user (#155).

Two layers:

- **Detection** (src/sync/worktree.ts): detectWorktreeIndexMismatch() compares
  the caller's git working-tree root vs the resolved index root via
  'git rev-parse --show-toplevel'. Best-effort; no git / not a repo / monorepo
  subdir / plain-ancestor index → no warning.
- **Surface**: codegraph status (CLI + MCP) embeds a verbose multi-line warning;
  every MCP read tool (search/context/trace/callers/callees/impact/explore/node/
  files) prefixes a compact one-line notice naming the borrowed index and the
  fix (codegraph init -i in the worktree). Detection is cached per session per
  start path, so it costs at most a single pair of 'git rev-parse' spawns per
  project no matter how many tool calls — respects the wall-clock-latency
  invariant.

Real-git tests (no mocking) cover both layers. Validated on macOS / Linux
(Docker) / Windows (Parallels VM); 11/11 worktree tests green on all three.

Closes #155

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
신주안 4 周之前
父節點
當前提交
4a4a37d135
共有 6 個文件被更改,包括 424 次插入10 次删除
  1. 14 0
      CHANGELOG.md
  2. 189 0
      __tests__/worktree-detection.test.ts
  3. 12 0
      src/bin/codegraph.ts
  4. 87 10
      src/mcp/tools.ts
  5. 8 0
      src/sync/index.ts
  6. 114 0
      src/sync/worktree.ts

+ 14 - 0
CHANGELOG.md

@@ -35,6 +35,20 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   standalone until it idles out — they never mix versions over the socket.
 
 ### Fixed
+- **Git worktrees no longer silently borrow another tree's index (#155).**
+  When a worktree is nested inside the main checkout — exactly what agent
+  tools that place worktrees under gitignored paths like
+  `.claude/worktrees/<name>/` do — running CodeGraph from that worktree used
+  to walk *up* to the main checkout's `.codegraph/` and silently return that
+  tree's code (usually a different branch). Symbols changed only in the
+  worktree were invisible and nothing told you. Now `codegraph status` (CLI +
+  MCP) calls out the conflict explicitly, and every MCP read tool
+  (`codegraph_search`/`context`/`trace`/`callers`/`callees`/`impact`/
+  `explore`/`node`/`files`) prefixes a one-line notice naming the borrowed
+  index and the fix (`codegraph init -i` in the worktree). Detection is
+  best-effort (no git / not a repo / monorepo subdir → no warning) and runs
+  once per session per start path, so it never costs more than a single pair
+  of `git rev-parse` invocations.
 - **The file watcher no longer exhausts the OS file-watch budget on large
   repos (#276).** It used to register a recursive watch over the *entire*
   project — `node_modules/`, build output, caches and all — and filter only

+ 189 - 0
__tests__/worktree-detection.test.ts

@@ -0,0 +1,189 @@
+/**
+ * Git worktree index-mismatch detection (issue #155).
+ *
+ * A CodeGraph index is resolved by walking up to the nearest `.codegraph/`.
+ * When a worktree is nested inside the main checkout, that walk reaches the
+ * MAIN checkout's index and a query silently returns the main branch's code
+ * instead of the worktree's. `detectWorktreeIndexMismatch` spots exactly this
+ * case so callers can warn.
+ *
+ * These tests drive real `git` against real temp worktrees — no mocking — so
+ * they exercise the same `git rev-parse --show-toplevel` behavior production
+ * relies on.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { execFileSync } from 'child_process';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import {
+  detectWorktreeIndexMismatch,
+  worktreeMismatchWarning,
+  gitWorktreeRoot,
+} from '../src/sync/worktree';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+
+function git(cwd: string, ...args: string[]): void {
+  execFileSync('git', args, { cwd, stdio: ['ignore', 'ignore', 'ignore'] });
+}
+
+/** realpath so macOS /var → /private/var symlinking doesn't break equality. */
+function real(p: string): string {
+  return fs.realpathSync(path.resolve(p));
+}
+
+describe('detectWorktreeIndexMismatch (issue #155)', () => {
+  let mainRepo: string;   // main checkout — owns the .codegraph index
+  let worktree: string;   // a linked worktree nested inside the main checkout
+  let nonGit: string;     // a directory outside any git repo
+
+  beforeEach(() => {
+    mainRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wt-main-'));
+    nonGit = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wt-plain-'));
+
+    git(mainRepo, 'init', '-q');
+    git(mainRepo, 'config', 'user.email', 'test@example.com');
+    git(mainRepo, 'config', 'user.name', 'Test');
+    git(mainRepo, 'config', 'commit.gpgsign', 'false');
+    fs.writeFileSync(path.join(mainRepo, 'README.md'), '# main\n');
+    git(mainRepo, 'add', '.');
+    git(mainRepo, 'commit', '-q', '-m', 'init');
+
+    // Nest the worktree under the main checkout, mirroring tools that place
+    // worktrees in (gitignored) subpaths like `.claude/worktrees/<name>/`.
+    worktree = path.join(mainRepo, 'wt');
+    git(mainRepo, 'worktree', 'add', '-q', '-b', 'feature', worktree);
+  });
+
+  afterEach(() => {
+    try { git(mainRepo, 'worktree', 'remove', '--force', worktree); } catch { /* best effort */ }
+    fs.rmSync(mainRepo, { recursive: true, force: true });
+    fs.rmSync(nonGit, { recursive: true, force: true });
+  });
+
+  it('flags a worktree borrowing the main checkout index', () => {
+    const m = detectWorktreeIndexMismatch(worktree, mainRepo);
+    expect(m).not.toBeNull();
+    expect(m!.worktreeRoot).toBe(real(worktree));
+    expect(m!.indexRoot).toBe(real(mainRepo));
+  });
+
+  it('returns null when the index lives in the same working tree', () => {
+    expect(detectWorktreeIndexMismatch(mainRepo, mainRepo)).toBeNull();
+    expect(detectWorktreeIndexMismatch(worktree, worktree)).toBeNull();
+  });
+
+  it('returns null for a subdirectory of the same working tree', () => {
+    const sub = path.join(mainRepo, 'src');
+    fs.mkdirSync(sub);
+    expect(detectWorktreeIndexMismatch(sub, mainRepo)).toBeNull();
+  });
+
+  it('returns null when startPath is not in a git repo', () => {
+    expect(detectWorktreeIndexMismatch(nonGit, mainRepo)).toBeNull();
+  });
+
+  it('returns null when the index root is a plain (non-worktree) directory', () => {
+    // startPath is a real worktree, but the index sits in an unrelated non-git
+    // dir — that's "index in an ancestor", not "borrowed another worktree".
+    expect(detectWorktreeIndexMismatch(worktree, nonGit)).toBeNull();
+  });
+
+  it('gitWorktreeRoot reports each tree distinctly', () => {
+    expect(gitWorktreeRoot(worktree)).toBe(real(worktree));
+    expect(gitWorktreeRoot(mainRepo)).toBe(real(mainRepo));
+    expect(gitWorktreeRoot(nonGit)).toBeNull();
+  });
+
+  it('warning names both trees and the fix', () => {
+    const msg = worktreeMismatchWarning(detectWorktreeIndexMismatch(worktree, mainRepo)!);
+    expect(msg).toContain(real(worktree));
+    expect(msg).toContain(real(mainRepo));
+    expect(msg).toContain('codegraph init');
+  });
+});
+
+/**
+ * The detection above only helps if it reaches the agent. Agents call the read
+ * tools (search/context/trace/…), almost never status — so the mismatch notice
+ * has to ride on every read tool's result, not just status. These tests drive
+ * the real `ToolHandler.execute` chokepoint against a real index whose default
+ * project resolves UP from a nested worktree to the main checkout.
+ */
+describe('worktree mismatch surfaces on hot read tools (issue #155)', () => {
+  let mainRepo: string;
+  let worktree: string;
+  let cg: CodeGraph;
+  let handler: ToolHandler;
+
+  beforeEach(async () => {
+    mainRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wt-tool-'));
+    git(mainRepo, 'init', '-q');
+    git(mainRepo, 'config', 'user.email', 'test@example.com');
+    git(mainRepo, 'config', 'user.name', 'Test');
+    git(mainRepo, 'config', 'commit.gpgsign', 'false');
+    fs.mkdirSync(path.join(mainRepo, 'src'));
+    fs.writeFileSync(path.join(mainRepo, 'src', 'a.ts'), 'export function mainOnly() { return 1; }\n');
+    git(mainRepo, 'add', '.');
+    git(mainRepo, 'commit', '-q', '-m', 'init');
+
+    // The index lives in the MAIN checkout.
+    cg = CodeGraph.initSync(mainRepo);
+    await cg.indexAll();
+
+    // Nested worktree, mirroring tools that place them under .claude/worktrees/<name>/.
+    worktree = path.join(mainRepo, 'wt');
+    git(mainRepo, 'worktree', 'add', '-q', '-b', 'feature', worktree);
+
+    handler = new ToolHandler(cg);
+  });
+
+  afterEach(() => {
+    try { cg.destroy(); } catch { /* best effort */ }
+    try { git(mainRepo, 'worktree', 'remove', '--force', worktree); } catch { /* best effort */ }
+    fs.rmSync(mainRepo, { recursive: true, force: true });
+  });
+
+  it('prefixes a compact notice on codegraph_search run from a nested worktree', async () => {
+    handler.setDefaultProjectHint(worktree);
+    const res = await handler.execute('codegraph_search', { query: 'mainOnly' });
+    const text = res.content[0].text;
+    expect(res.isError).toBeFalsy();
+    expect(text).toContain('different git worktree');
+    expect(text).toContain(real(worktree));
+    expect(text).toContain('codegraph init');
+  });
+
+  it('does NOT prefix when the default project is the main checkout itself', async () => {
+    handler.setDefaultProjectHint(mainRepo);
+    const res = await handler.execute('codegraph_search', { query: 'mainOnly' });
+    expect(res.content[0].text).not.toContain('different git worktree');
+  });
+
+  it('still shows the verbose warning on codegraph_status', async () => {
+    handler.setDefaultProjectHint(worktree);
+    const res = await handler.execute('codegraph_status', {});
+    const text = res.content[0].text;
+    expect(text).toContain('different git working tree');
+    expect(text).toContain(real(worktree));
+  });
+
+  it('caches detection — a later tool call needs no further git spawn', async () => {
+    handler.setDefaultProjectHint(worktree);
+    // First call computes + caches the mismatch (this is the only git spawn).
+    const first = await handler.execute('codegraph_search', { query: 'mainOnly' });
+    expect(first.content[0].text).toContain('different git worktree');
+
+    // Make git unreachable. A fresh detection would now return null (no notice);
+    // the notice still appearing on a *different* tool proves it came from cache.
+    const savedPath = process.env.PATH;
+    process.env.PATH = '';
+    try {
+      const second = await handler.execute('codegraph_context', { task: 'mainOnly' });
+      expect(second.content[0].text).toContain('different git worktree');
+    } finally {
+      process.env.PATH = savedPath;
+    }
+  });
+});

+ 12 - 0
src/bin/codegraph.ts

@@ -26,6 +26,7 @@ import { Command } from 'commander';
 import * as path from 'path';
 import * as fs from 'fs';
 import { getCodeGraphDir, isInitialized } from '../directory';
+import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree';
 import { createShimmerProgress } from '../ui/shimmer-progress';
 import { getGlyphs } from '../ui/glyphs';
 
@@ -692,6 +693,11 @@ program
   .option('-j, --json', 'Output as JSON')
   .action(async (pathArg: string | undefined, options: { json?: boolean }) => {
     const projectPath = resolveProjectPath(pathArg);
+    // The directory the user actually ran from, before walking up to the index
+    // root. Used to detect when the resolved index lives in a different git
+    // working tree (e.g. a nested worktree borrowing the main checkout's index).
+    const startPath = path.resolve(pathArg || process.cwd());
+    const worktreeMismatch = detectWorktreeIndexMismatch(startPath, projectPath);
 
     try {
       if (!isInitialized(projectPath)) {
@@ -731,6 +737,9 @@ program
             modified: changes.modified.length,
             removed: changes.removed.length,
           },
+          worktreeMismatch: worktreeMismatch
+            ? { worktreeRoot: worktreeMismatch.worktreeRoot, indexRoot: worktreeMismatch.indexRoot }
+            : null,
         }));
         cg.destroy();
         return;
@@ -740,6 +749,9 @@ program
 
       // Project info
       console.log(chalk.cyan('Project:'), projectPath);
+      if (worktreeMismatch) {
+        warn(worktreeMismatchWarning(worktreeMismatch));
+      }
       console.log();
 
       // Index stats

+ 87 - 10
src/mcp/tools.ts

@@ -5,6 +5,12 @@
  */
 
 import CodeGraph, { findNearestCodeGraphRoot } from '../index';
+import {
+  detectWorktreeIndexMismatch,
+  worktreeMismatchWarning,
+  worktreeMismatchNotice,
+  type WorktreeIndexMismatch,
+} from '../sync/worktree';
 import type { Node, Edge, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
 import { createHash } from 'crypto';
 import {
@@ -532,6 +538,12 @@ export class ToolHandler {
   // The directory the server last searched for a default project. Surfaced in
   // the "not initialized" error so users can see why detection missed.
   private defaultProjectHint: string | null = null;
+  // Per-start-path cache of the git worktree/index mismatch (issue #155). The
+  // mismatch is a fixed property of (where the request came from → which
+  // .codegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
+  // once and every later tool call reuses the result — never shelling out to
+  // git on the hot path. `undefined` = not computed yet; `null` = no mismatch.
+  private worktreeMismatchCache: Map<string, WorktreeIndexMismatch | null> = new Map();
 
   constructor(private cg: CodeGraph | null) {}
 
@@ -696,6 +708,7 @@ export class ToolHandler {
       cg.close();
     }
     this.projectCache.clear();
+    this.worktreeMismatchCache.clear();
   }
 
   /**
@@ -742,6 +755,53 @@ export class ToolHandler {
     return value;
   }
 
+  /**
+   * Cached git worktree/index mismatch for a tool call's effective project.
+   *
+   * The "effective project" is what the request targets: an explicit
+   * `projectPath` arg, else the directory the server resolved its default
+   * project from (`defaultProjectHint`), else cwd. Memoized per start path —
+   * see `worktreeMismatchCache`. Best-effort: if the project can't be resolved
+   * (e.g. nothing initialized yet), it reports "no mismatch" so a tool is never
+   * broken by this check.
+   */
+  private worktreeMismatchFor(projectPath?: string): WorktreeIndexMismatch | null {
+    const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd();
+    const cached = this.worktreeMismatchCache.get(startPath);
+    if (cached !== undefined) return cached;
+
+    let mismatch: WorktreeIndexMismatch | null = null;
+    try {
+      mismatch = detectWorktreeIndexMismatch(startPath, this.getCodeGraph(projectPath).getProjectRoot());
+    } catch {
+      // No resolvable project (or any other resolution error) → nothing to warn.
+      mismatch = null;
+    }
+    this.worktreeMismatchCache.set(startPath, mismatch);
+    return mismatch;
+  }
+
+  /**
+   * Prefix a successful read-tool result with a compact worktree-mismatch
+   * notice when the resolved index belongs to a different git working tree than
+   * the caller's (issue #155). Without this, an agent in a nested worktree
+   * silently trusts main-branch results. No-op on error results and when there
+   * is no mismatch. `codegraph_status` is excluded — it embeds its own verbose
+   * warning — so it stays out of this path.
+   */
+  private withWorktreeNotice(result: ToolResult, projectPath?: string): ToolResult {
+    if (result.isError) return result;
+    const mismatch = this.worktreeMismatchFor(projectPath);
+    if (!mismatch) return result;
+
+    const notice = worktreeMismatchNotice(mismatch);
+    const [first, ...rest] = result.content;
+    if (first && first.type === 'text') {
+      return { ...result, content: [{ type: 'text', text: `${notice}\n\n${first.text}` }, ...rest] };
+    }
+    return result;
+  }
+
   /**
    * Execute a tool by name
    */
@@ -771,30 +831,35 @@ export class ToolHandler {
         if (typeof check === 'object' && check !== undefined) return check;
       }
 
+      // Read tools resolve through a single result variable so the worktree
+      // mismatch notice can be prefixed in one place (issue #155). status is
+      // returned directly — it embeds its own verbose warning.
+      let result: ToolResult;
       switch (toolName) {
         case 'codegraph_search':
-          return await this.handleSearch(args);
+          result = await this.handleSearch(args); break;
         case 'codegraph_context':
-          return await this.handleContext(args);
+          result = await this.handleContext(args); break;
         case 'codegraph_callers':
-          return await this.handleCallers(args);
+          result = await this.handleCallers(args); break;
         case 'codegraph_callees':
-          return await this.handleCallees(args);
+          result = await this.handleCallees(args); break;
         case 'codegraph_impact':
-          return await this.handleImpact(args);
+          result = await this.handleImpact(args); break;
         case 'codegraph_explore':
-          return await this.handleExplore(args);
+          result = await this.handleExplore(args); break;
         case 'codegraph_node':
-          return await this.handleNode(args);
+          result = await this.handleNode(args); break;
         case 'codegraph_status':
           return await this.handleStatus(args);
         case 'codegraph_files':
-          return await this.handleFiles(args);
+          result = await this.handleFiles(args); break;
         case 'codegraph_trace':
-          return await this.handleTrace(args);
+          result = await this.handleTrace(args); break;
         default:
           return this.errorResult(`Unknown tool: ${toolName}`);
       }
+      return this.withWorktreeNotice(result, args.projectPath as string | undefined);
     } catch (err) {
       return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
     }
@@ -1954,14 +2019,26 @@ export class ToolHandler {
     const cg = this.getCodeGraph(args.projectPath as string | undefined);
     const stats = cg.getStats();
 
+    // Warn when this index actually belongs to a different git working tree
+    // (e.g. the server resolved up from a nested worktree to the main checkout).
+    // Queries then reflect that tree's branch, not the worktree being edited.
+    // status shows the verbose, multi-line form; the read tools get the compact
+    // one-liner via withWorktreeNotice. Both share the cached detection.
+    const mismatch = this.worktreeMismatchFor(args.projectPath as string | undefined);
+
     const lines: string[] = [
       '## CodeGraph Status',
       '',
+    ];
+    if (mismatch) {
+      lines.push(`> ⚠ ${worktreeMismatchWarning(mismatch).replace(/\n/g, '\n> ')}`, '');
+    }
+    lines.push(
       `**Files indexed:** ${stats.fileCount}`,
       `**Total nodes:** ${stats.nodeCount}`,
       `**Total edges:** ${stats.edgeCount}`,
       `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
-    ];
+    );
 
     // Surface the active SQLite backend (node:sqlite, Node's built-in real
     // SQLite — full WAL + FTS5, no native build).

+ 8 - 0
src/sync/index.ts

@@ -8,6 +8,7 @@
  * - FileWatcher: Debounced fs.watch that auto-triggers sync on file changes
  * - Watch policy: decides when the watcher must be disabled (e.g. WSL2 /mnt)
  * - Git sync hooks: opt-in commit/merge/checkout hooks when watching is off
+ * - Git worktree awareness: detect when a query borrows another tree's index
  * - Content hashing for change detection (in extraction module)
  * - Incremental reindexing (in extraction module)
  */
@@ -23,3 +24,10 @@ export {
   type GitHookName,
   type GitHookResult,
 } from './git-hooks';
+export {
+  gitWorktreeRoot,
+  detectWorktreeIndexMismatch,
+  worktreeMismatchWarning,
+  worktreeMismatchNotice,
+  type WorktreeIndexMismatch,
+} from './worktree';

+ 114 - 0
src/sync/worktree.ts

@@ -0,0 +1,114 @@
+/**
+ * Git Worktree Awareness
+ *
+ * A CodeGraph index lives in a `.codegraph/` directory and is resolved by
+ * walking up parent directories to the nearest one (see
+ * `findNearestCodeGraphRoot`). That walk is unaware of git worktrees: when a
+ * worktree is created *inside* the main checkout (e.g. some tools place them
+ * under `.gitignore`d paths like `.claude/worktrees/<name>/`), a command run
+ * from the worktree walks up and silently resolves the MAIN checkout's index.
+ *
+ * Every query then returns results from the main tree's code — usually a
+ * different branch — rather than the worktree the user is actually editing.
+ * Symbols added or changed only in the worktree are invisible. This module
+ * detects that "borrowed index" situation so callers can warn about it.
+ *
+ * Detection is best-effort: when git is unavailable or the path isn't a repo,
+ * it reports "no mismatch" and callers carry on unchanged.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import { execFileSync } from 'child_process';
+
+/**
+ * Absolute, symlink-resolved toplevel of the git working tree that `dir`
+ * belongs to, or null when `dir` isn't inside a git repo (or git is missing).
+ *
+ * `git rev-parse --show-toplevel` returns the per-worktree root: the main
+ * checkout and each linked worktree report their own distinct directory, which
+ * is exactly the distinction this module relies on.
+ */
+export function gitWorktreeRoot(dir: string): string | null {
+  try {
+    const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
+      cwd: dir,
+      encoding: 'utf8',
+      stdio: ['ignore', 'pipe', 'ignore'],
+    }).trim();
+    return out ? realpath(out) : null;
+  } catch {
+    return null;
+  }
+}
+
+export interface WorktreeIndexMismatch {
+  /** The git working tree the command was run from. */
+  worktreeRoot: string;
+  /** The (different) working tree whose `.codegraph` index is being used. */
+  indexRoot: string;
+}
+
+/**
+ * Detect when `startPath` lives in one git working tree but the resolved
+ * CodeGraph index (`indexRoot`) belongs to a *different* working tree.
+ *
+ * Returns null — meaning "nothing to warn about" — when:
+ *   - `startPath` isn't in a git repo (or git is unavailable),
+ *   - the index already lives in `startPath`'s own working tree, or
+ *   - `indexRoot` isn't itself a working-tree root (an unrelated parent dir
+ *     that merely happens to contain a `.codegraph/`), which keeps non-git
+ *     and monorepo-subdir layouts from producing false warnings.
+ */
+export function detectWorktreeIndexMismatch(
+  startPath: string,
+  indexRoot: string,
+): WorktreeIndexMismatch | null {
+  const worktreeRoot = gitWorktreeRoot(startPath);
+  if (!worktreeRoot) return null;
+
+  const resolvedIndexRoot = realpath(indexRoot);
+  if (worktreeRoot === resolvedIndexRoot) return null;
+
+  // Only flag it when the index root is itself a real working-tree root. This
+  // distinguishes "borrowed another worktree's index" from "index sits in a
+  // plain ancestor directory", and avoids warning outside git entirely.
+  if (gitWorktreeRoot(resolvedIndexRoot) !== resolvedIndexRoot) return null;
+
+  return { worktreeRoot, indexRoot: resolvedIndexRoot };
+}
+
+/** One-line-per-fact warning describing a detected mismatch. */
+export function worktreeMismatchWarning(m: WorktreeIndexMismatch): string {
+  return (
+    `This CodeGraph index belongs to a different git working tree.\n` +
+    `  Running in: ${m.worktreeRoot}\n` +
+    `  Index from: ${m.indexRoot}\n` +
+    `Results reflect that tree's code (often a different branch), not this worktree — ` +
+    `symbols changed only here are missing. Run "codegraph init -i" in this worktree ` +
+    `for a worktree-local index.`
+  );
+}
+
+/**
+ * Compact, single-line variant for prefixing a tool's result. Read tools
+ * return their answer inline, so the heads-up has to ride on the same payload
+ * the agent is already reading — a multi-line block would bury the result.
+ */
+export function worktreeMismatchNotice(m: WorktreeIndexMismatch): string {
+  return (
+    `⚠ CodeGraph results below come from a different git worktree (${m.indexRoot}), ` +
+    `not where you're working (${m.worktreeRoot}) — they may reflect another branch, ` +
+    `and symbols changed only here are missing. Run "codegraph init -i" here for a ` +
+    `worktree-local index.`
+  );
+}
+
+/** Resolve symlinks where possible so tmp/realpath quirks don't break equality. */
+function realpath(p: string): string {
+  try {
+    return fs.realpathSync(path.resolve(p));
+  } catch {
+    return path.resolve(p);
+  }
+}