Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions __tests__/worktree-detection.test.ts
Original file line number Diff line number Diff line change
@@ -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/<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');
});
});
12 changes: 12 additions & 0 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
Expand All @@ -728,6 +737,9 @@ program

// Project info
console.log(chalk.cyan('Project:'), projectPath);
if (worktreeMismatch) {
warn(worktreeMismatchWarning(worktreeMismatch));
}
console.log();

// Index stats
Expand Down
15 changes: 14 additions & 1 deletion src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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).
Expand Down
7 changes: 7 additions & 0 deletions src/sync/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand All @@ -23,3 +24,9 @@ export {
type GitHookName,
type GitHookResult,
} from './git-hooks';
export {
gitWorktreeRoot,
detectWorktreeIndexMismatch,
worktreeMismatchWarning,
type WorktreeIndexMismatch,
} from './worktree';
100 changes: 100 additions & 0 deletions src/sync/worktree.ts
Original file line number Diff line number Diff line change
@@ -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/<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.`
);
}

/** 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);
}
}