diff --git a/__tests__/worktree-detection.test.ts b/__tests__/worktree-detection.test.ts new file mode 100644 index 00000000..d0fd934a --- /dev/null +++ b/__tests__/worktree-detection.test.ts @@ -0,0 +1,103 @@ +/** + * 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'; + +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//`. + 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'); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index dac8ce1e..04c4e08f 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -22,6 +22,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'; @@ -680,6 +681,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)) { @@ -719,6 +725,9 @@ program modified: changes.modified.length, removed: changes.removed.length, }, + worktreeMismatch: worktreeMismatch + ? { worktreeRoot: worktreeMismatch.worktreeRoot, indexRoot: worktreeMismatch.indexRoot } + : null, })); cg.destroy(); return; @@ -728,6 +737,9 @@ program // Project info console.log(chalk.cyan('Project:'), projectPath); + if (worktreeMismatch) { + warn(worktreeMismatchWarning(worktreeMismatch)); + } console.log(); // Index stats diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 3ceb8551..763d5a6f 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -5,6 +5,7 @@ */ import CodeGraph, { findNearestCodeGraphRoot } from '../index'; +import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree'; import type { Node, Edge, SearchResult, Subgraph, TaskContext, NodeKind } from '../types'; import { createHash } from 'crypto'; import { @@ -1351,14 +1352,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. + const startPath = + (args.projectPath as string | undefined) ?? this.defaultProjectHint ?? process.cwd(); + const mismatch = detectWorktreeIndexMismatch(startPath, cg.getProjectRoot()); + 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). diff --git a/src/sync/index.ts b/src/sync/index.ts index 1857c5a4..bdd8cdf8 100644 --- a/src/sync/index.ts +++ b/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,9 @@ export { type GitHookName, type GitHookResult, } from './git-hooks'; +export { + gitWorktreeRoot, + detectWorktreeIndexMismatch, + worktreeMismatchWarning, + type WorktreeIndexMismatch, +} from './worktree'; diff --git a/src/sync/worktree.ts b/src/sync/worktree.ts new file mode 100644 index 00000000..94cea7e4 --- /dev/null +++ b/src/sync/worktree.ts @@ -0,0 +1,100 @@ +/** + * 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//`), 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.` + ); +} + +/** 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); + } +}