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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- MCP tools no longer falsely warn "index belongs to a different git working tree" when a linked worktree has its own `.codegraph/` index. If you run `codegraph init` inside a worktree that is nested under your main checkout, tool calls that explicitly pass `projectPath` pointing at that worktree now resolve the worktree-local index and produce clean results — no spurious cross-tree notice. (#926)
- `codegraph index` now rebuilds the full graph from scratch, so it produces the same result as a fresh `codegraph init` instead of reporting "0 nodes, 0 edges" and looking like it wiped your index. Previously, re-running `index` on an unchanged project skipped every file (their contents hadn't changed) and showed an empty-looking summary; it now clears and re-indexes for an honest, complete rebuild every time. Use `codegraph sync` for fast incremental updates between full rebuilds. Thanks @Arc-univer. (#874)
- The file watcher that auto-syncs the graph now fails cleanly when live watching can no longer be trusted, instead of looking healthy while the index quietly goes stale. If the operating system runs out of file-watch resources, or another process holds the write lock far longer than a normal save, CodeGraph now disables auto-sync once — with a single clear message telling you to run `codegraph sync` (or rely on the git sync hooks) to refresh — rather than retrying forever or repeating the same error on a loop. And while auto-sync is disabled, CodeGraph's tool responses (and `codegraph status`) now say so plainly, so your AI agent knows to read files directly instead of trusting a frozen index. This mostly matters for long-running MCP/daemon sessions, which could otherwise keep serving stale results while appearing to work. Thanks @thismilktea. (#876)
- On Linux, hitting the kernel's inotify watch limit on a large project no longer silently leaves half the tree unwatched. CodeGraph now tells you once — naming the exact setting to raise (`fs.inotify.max_user_watches`, e.g. `sudo sysctl fs.inotify.max_user_watches=1048576`) — and keeps live-watching the directories it could register while `codegraph sync` (or the git sync hooks) covers the rest. (#876)
Expand Down
109 changes: 109 additions & 0 deletions __tests__/worktree-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,115 @@ describe('detectWorktreeIndexMismatch (issue #155)', () => {
});
});

/**
* Regression tests for issue #926: a worktree-local index must not be falsely
* flagged as a mismatch when the MCP tool call explicitly passes `projectPath`
* pointing at that worktree.
*
* Two stale-cache bugs combined to produce the false positive:
*
* Bug A — `projectCache` staleness: a prior tool call with `projectPath=wt`
* (before the worktree index existed) cached `wt → mainRepoCG`. After the
* worktree index was created the stale entry persisted, so `getCodeGraph(wt)`
* kept returning the main repo's CodeGraph.
*
* Bug B — `worktreeMismatchCache` key collision: the mismatch cache was keyed
* on `startPath` alone. A call WITHOUT `projectPath` (where
* `startPath = defaultProjectHint = wt`, `indexRoot = mainRepo`) cached a
* legitimate mismatch under key "wt". A later call WITH `projectPath=wt`
* (where `indexRoot = wt`) hit the same key and returned the stale entry.
*
* Fix A: re-validate `projectCache` entries whose root is a *parent* of
* `projectPath`; evict when `findNearestCodeGraphRoot` now returns
* something closer.
* Fix B: key `worktreeMismatchCache` on `${startPath}\0${indexRoot}` so the
* two calls above get independent entries.
*/
describe('worktree-local index not falsely flagged when projectPath is explicit (issue #926)', () => {
let mainRepo: string;
let worktree: string;
let mainCg: CodeGraph;
let worktreeCg: CodeGraph;

beforeEach(async () => {
mainRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-926-main-'));
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 mainFn() {}\n');
git(mainRepo, 'add', '.');
git(mainRepo, 'commit', '-q', '-m', 'init');

mainCg = CodeGraph.initSync(mainRepo);
await mainCg.indexAll();

// Worktree nested inside the main checkout (mirrors the issue reporter's setup).
worktree = path.join(mainRepo, 'wt');
git(mainRepo, 'worktree', 'add', '-q', '-b', 'feature', worktree);

// The worktree has its OWN .codegraph/ index (the scenario from #926).
worktreeCg = CodeGraph.initSync(worktree);
await worktreeCg.indexAll();
});

afterEach(() => {
try { mainCg.destroy(); } catch { /* best effort */ }
try { worktreeCg.destroy(); } catch { /* best effort */ }
try { git(mainRepo, 'worktree', 'remove', '--force', worktree); } catch { /* best effort */ }
fs.rmSync(mainRepo, { recursive: true, force: true });
});

it('no mismatch when the handler is initialized with the worktree-local index', async () => {
// When the ToolHandler's own CodeGraph IS the worktree's index, tool calls
// (with or without explicit projectPath) must not emit a mismatch notice.
// getCodeGraph returns this.cg directly when resolvedRoot === this.cg.getProjectRoot(),
// so this path exercises detectWorktreeIndexMismatch(worktree, worktree) → null.
const handler = new ToolHandler(worktreeCg);
handler.setDefaultProjectHint(worktree);
const res = await handler.execute('codegraph_search', { query: 'mainFn', projectPath: worktree });
expect(res.isError).toBeFalsy();
expect(res.content[0].text).not.toContain('different git worktree');
});

it('mismatch IS reported when the main-repo handler is hinted at the worktree', async () => {
// Baseline: a handler serving mainCg with hint=worktree should warn.
const handler = new ToolHandler(mainCg);
handler.setDefaultProjectHint(worktree);
const res = await handler.execute('codegraph_search', { query: 'mainFn' });
expect(res.content[0].text).toContain('different git worktree');
});

it('(startPath, indexRoot) cache key: worktree-hinted main-repo and worktree-local handlers produce independent mismatch entries', async () => {
// Fix B: worktreeMismatchCache is keyed on `startPath\0indexRoot`, not
// startPath alone. With the old single-key scheme, a mismatch cached by the
// main-repo handler (key "worktree") would be returned verbatim by a later
// call whose indexRoot is actually "worktree" itself — a false positive.
//
// Both handlers below share startPath = worktree (via defaultProjectHint)
// but have different indexRoots (mainRepo vs worktree), so they must get
// independent cache entries and produce opposite results.
const mainHandler = new ToolHandler(mainCg);
mainHandler.setDefaultProjectHint(worktree);

const wtHandler = new ToolHandler(worktreeCg);
wtHandler.setDefaultProjectHint(worktree);

// mainHandler: startPath=worktree, indexRoot=mainRepo → mismatch
const first = await mainHandler.execute('codegraph_search', { query: 'mainFn' });
expect(first.content[0].text).toContain('different git worktree');

// wtHandler: startPath=worktree, indexRoot=worktree → no mismatch
// With the old bug (key = startPath only), both handlers sharing the same
// startPath string would alias — but they have independent caches here,
// so this directly tests that (worktree, worktree) produces null.
const second = await wtHandler.execute('codegraph_search', { query: 'mainFn' });
expect(second.isError).toBeFalsy();
expect(second.content[0].text).not.toContain('different git worktree');
});
});

/**
* 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
Expand Down
46 changes: 40 additions & 6 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,9 +834,26 @@ export class ToolHandler {
return this.cg;
}

// Check cache first (using original path as key)
// Check cache first (using original path as key).
// Guard against stale entries: if the cached root is a *parent* of
// projectPath (a "walk-up" resolution), a worktree-local .codegraph/
// may have appeared since we cached it (issue #926). Re-run the walk
// in that case so a newly-initialised worktree index is picked up.
// When the cached root IS the projectPath itself, nothing closer can
// exist — skip the re-check to keep the hot path cheap.
if (this.projectCache.has(projectPath)) {
return this.projectCache.get(projectPath)!;
const entry = this.projectCache.get(projectPath)!;
const entryRoot = entry.getProjectRoot();
if (entryRoot === resolvePath(projectPath)) {
return entry; // direct hit — can't be stale
}
// Parent resolution — check if a closer index has appeared.
const freshRoot = findNearestCodeGraphRoot(projectPath);
if (!freshRoot || freshRoot === entryRoot) {
return entry; // still the same resolution
}
// A new/closer index exists — evict the stale entry and fall through.
this.projectCache.delete(projectPath);
}

// Reject sensitive system directories before opening. Only validate a
Expand Down Expand Up @@ -958,17 +975,34 @@ export class ToolHandler {
*/
private worktreeMismatchFor(projectPath?: string): WorktreeIndexMismatch | null {
const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd();
const cached = this.worktreeMismatchCache.get(startPath);

// Resolve the CodeGraph first so we can use its root as part of the cache
// key. Keying on startPath alone caused false positives (issue #926): a
// prior call without projectPath could cache a mismatch under the worktree
// path (startPath = defaultProjectHint = worktreePath, indexRoot =
// mainRepo), and a later call WITH projectPath=worktreePath would hit that
// stale entry even though getCodeGraph now correctly resolves the worktree's
// own index. Including indexRoot in the key ensures the two calls get
// independent cache entries.
let indexRoot: string;
try {
indexRoot = this.getCodeGraph(projectPath).getProjectRoot();
} catch {
// No resolvable project → nothing to warn about.
return null;
}

const cacheKey = `${startPath}\0${indexRoot}`;
const cached = this.worktreeMismatchCache.get(cacheKey);
if (cached !== undefined) return cached;

let mismatch: WorktreeIndexMismatch | null = null;
try {
mismatch = detectWorktreeIndexMismatch(startPath, this.getCodeGraph(projectPath).getProjectRoot());
mismatch = detectWorktreeIndexMismatch(startPath, indexRoot);
} catch {
// No resolvable project (or any other resolution error) → nothing to warn.
mismatch = null;
}
this.worktreeMismatchCache.set(startPath, mismatch);
this.worktreeMismatchCache.set(cacheKey, mismatch);
return mismatch;
}

Expand Down