fix(worktree): a linked worktree is recognized however Windows spells its path (#2937)

On Windows one directory has several spellings. VS Code and Cursor pass a
lowercase drive letter, NTFS ignores case, a subst drive is a second name
for a folder, and long names have 8.3 aliases. git answers with the
spelling on disk, but src/sync/worktree.ts compared its paths with the
caller's as strings, after a JS realpath that keeps the caller's spelling.
The main checkout's git common dir comes back relative (".git"), so it
took the caller's spelling while a linked worktree's came back absolute
in git's. A worktree inside the indexed checkout, spelled unlike git,
failed all four comparisons:
- detectWorktreeIndexMismatch found no mismatch, so codegraph status
  (text and --json) dropped the borrowed-index warning;
- nestedRepositoryBelow took the worktree for a different repository, so
  the MCP tools refused it as unindexed and told the agent not to call
  codegraph for it again.

Fix: the four comparisons now ask whether two paths name one directory,
with the identity canonicalProjectRoot already uses (the native realpath,
lowercased on Windows), falling back to the JS realpath where the native
call fails. Returned paths keep the caller's spelling for display. A
nested repository below an index root outside git is still reported, as
before.

Validation (Windows 11, Node 22):
- New Windows-gated tests with a lowercase drive letter, at the helper
  level and through ToolHandler (search and status, with and without a
  default project): the 3 behaviour tests fail on main's worktree.ts and
  pass with the fix. Both files: 32 passed.
- Fixture: a checkout with a worktree under .claude/worktrees and an
  embedded clone, spelled four ways (on-disk, lowercase drive, other
  capitals, subst drive). Helper verdicts wrong: 6 on main, 0 with the
  fix. codegraph status warned in 2 of 8 runs on main, 8 of 8 with the
  fix. The real MCP server refused the lowercase, capitals and subst
  spellings on main; with the fix it serves all four with the
  borrowed-index notice, codegraph_status warns, and the embedded clone
  is still refused. An 8.3 short name (the contributor's case) went from
  2 wrong verdicts to 0.

Reported in PR #2517 by @nk33-dev; this reimplements the fix
independently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-27 22:43:41 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 475e56b209
commit 45a29b4685
4 changed files with 122 additions and 4 deletions
+1
View File
@@ -70,6 +70,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Building CodeGraph from source with bun, as in `bun run build`, no longer runs until it is stopped: bun ran one step of the build as the whole build again, so it kept starting itself over. Building with npm is unchanged. Thanks @bompus.
- When CodeGraph can't open a project's database, it now says what went wrong: SQLite's error, the file's path, and what to check, such as another program holding the file open, a folder you can't write to, or a network share. Before, every such failure read "CodeGraph requires node:sqlite (Node.js 22.5+)", and an agent was told no project was loaded. That hint now appears only when Node really can't load its built-in SQLite, a case that used to end in an unhandled error instead.
- On Windows, a project at the root of a drive, such as a mapped network drive like `Z:\`, a `subst` drive or the root of a network share, now indexes. Before, every file was refused as "Path traversal blocked", so the index came out empty, and the code of such a project couldn't be shown either. Thanks @Kamilslaoui93.
- On Windows, a git worktree inside your main checkout, such as one under `.claude/worktrees/`, is now recognized however its path is written: with a lowercase drive letter, as VS Code and Cursor pass it, in different capitals, through a `subst` drive or by its short 8.3 name. Before, `codegraph status` didn't warn that the worktree was answered from the main checkout's index, and an agent working there was told the worktree wasn't indexed and to stop using CodeGraph for it. Thanks @nk33-dev.
- `codegraph upgrade` no longer wipes your agent instructions file, such as a global `AGENTS.md`, `CLAUDE.md` or `GEMINI.md`, when it refreshes CodeGraph's section in it: a stray `<!-- CODEGRAPH_START -->` line above that section, like one left behind when part of an older section was deleted by hand, made the refresh replace everything in between, often the whole file. CodeGraph now rewrites only its own section, in `codegraph install` and `codegraph uninstall` too, and keeps everything around it exactly as it was, Windows line endings included. Thanks @pzaj2 for the report. (#2506)
- `codegraph install`, `upgrade` and `uninstall` now write through an instructions or config file that is a symbolic link, such as a `~/.codex/AGENTS.md` or `~/.claude.json` linked from your dotfiles, instead of replacing the link with a copy: the shared file gets CodeGraph's settings, uninstall takes them out again, and the link stays in place. A link CodeGraph can't write through, like one to a read-only file or into a folder that doesn't exist, is now reported by name and left alone, and an upgrade still refreshes your other agents. Thanks @jguerra6 for the report and @danusha2345 for the first fix. (#2721)
- `codegraph install` and `upgrade` no longer break Codex's `config.toml` when CodeGraph's `[mcp_servers.codegraph]` entry is written differently from how CodeGraph writes it, such as indented, with spaces or quotes inside the brackets, or with Windows line endings: the entry is now updated in place, where before a second copy was added and Codex could load none of the file. A file already broken that way is repaired on the next install or upgrade, an example of the entry inside a multiline setting such as `developer_instructions` is no longer mistaken for it, uninstall removes the entry however it is written, and an entry you wrote with dotted keys or as an inline table is left as you wrote it. When your `mcp_servers` settings are one inline table, CodeGraph now says it can't add itself instead of breaking the file. Thanks @dencoseca for the report, and @danusha2345 and @rudycelekli for the first fixes. (#2562)
@@ -155,6 +155,31 @@ describe('projectPath inside a nested git repository the ancestor index excludes
expect(text(res)).toContain('parent-only.ts');
});
it.runIf(process.platform === 'win32')('serves that linked worktree spelled with a lowercase drive letter too (#2517)', async () => {
// VS Code and Cursor pass `d:\…` while git answers `D:\…`: one directory, so
// the worktree is still the borrowed-index case, never a repository of its own.
const wt = path.join(parent, 'wt');
git(parent, 'worktree', 'add', '-q', '-b', 'feature', wt);
const lowerWt = wt.charAt(0).toLowerCase() + wt.slice(1);
expect(lowerWt).not.toBe(wt);
__setLoadCodeGraphForTests(CodeGraph);
// The server's default project is the parent; then no default project, so
// the index is opened under the caller's spelling.
for (const defaultCg of [cg, null]) {
if (handler) await handler.closeAll();
handler = new ToolHandler(defaultCg);
const res = await handler.execute('codegraph_search', { query: 'parentOnly', projectPath: lowerWt });
expect(res.isError).toBeUndefined();
expect(text(res)).not.toMatch(/isn't indexed/);
expect(text(res)).toContain('different git worktree');
expect(text(res)).toContain('parent-only.ts');
const status = await handler.execute('codegraph_status', { projectPath: lowerWt });
expect(status.isError).toBeUndefined();
expect(text(status)).toContain('different git working tree');
}
});
it('notices the nested repository joining the index without a restart', async () => {
handler = new ToolHandler(cg);
const before = await handler.execute('codegraph_search', { query: 'nestedOnly', projectPath: nested });
+67
View File
@@ -18,6 +18,7 @@ import * as os from 'os';
import * as path from 'path';
import {
detectWorktreeIndexMismatch,
nestedRepositoryBelow,
worktreeMismatchWarning,
gitWorktreeRoot,
gitCommonDir,
@@ -105,6 +106,72 @@ describe('detectWorktreeIndexMismatch (issue #155)', () => {
});
});
/**
* Windows spells one directory several ways. VS Code and Cursor pass a
* lowercase drive letter (`d:\repo`), a `subst` drive is a second name for a
* folder, and NTFS ignores case, while git answers with the spelling on disk.
* Compared as strings, a linked worktree spelled unlike git looked like a
* different directory: `status` lost the borrowed-index warning, and the MCP
* tools refused the worktree as an unindexed repository of its own (#2517).
* A lowercase drive letter needs no `subst` mapping or admin rights to show it.
*/
describe('a linked worktree spelled with a lowercase drive letter (#2517)', () => {
let mainRepo: string;
let worktree: string;
let embedded: string; // a different repository nested below: still reported
const lowerDrive = (p: string) => p.charAt(0).toLowerCase() + p.slice(1);
beforeEach(() => {
mainRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wt-2517-'));
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, '.gitignore'), 'wt/\nembedded/\n');
fs.mkdirSync(path.join(mainRepo, 'src'));
fs.writeFileSync(path.join(mainRepo, 'src', 'a.ts'), 'export const a = 1;\n');
git(mainRepo, 'add', '.');
git(mainRepo, 'commit', '-q', '-m', 'init');
worktree = path.join(mainRepo, 'wt');
git(mainRepo, 'worktree', 'add', '-q', '-b', 'feature', worktree);
embedded = path.join(mainRepo, 'embedded');
fs.mkdirSync(embedded);
git(embedded, 'init', '-q');
});
afterEach(() => {
try { git(mainRepo, 'worktree', 'remove', '--force', worktree); } catch { /* best effort */ }
fs.rmSync(mainRepo, { recursive: true, force: true });
});
it.runIf(process.platform === 'win32')('still flags the borrowed index, keeping the caller\'s spelling', () => {
expect(mainRepo).toMatch(/^[A-Z]:\\/);
const lowerMain = lowerDrive(mainRepo);
const lowerWorktree = lowerDrive(worktree);
for (const [start, index] of [[lowerWorktree, lowerMain], [worktree, lowerMain], [lowerWorktree, mainRepo]]) {
const m = detectWorktreeIndexMismatch(start, index);
expect(m, `${start} on ${index}`).not.toBeNull();
expect(m!.indexRoot).toBe(real(index));
}
});
it.runIf(process.platform === 'win32')('is not a mismatch from the index root\'s own working tree', () => {
const lowerMain = lowerDrive(mainRepo);
expect(detectWorktreeIndexMismatch(lowerMain, mainRepo)).toBeNull();
expect(detectWorktreeIndexMismatch(mainRepo, lowerMain)).toBeNull();
expect(detectWorktreeIndexMismatch(path.join(lowerMain, 'src'), mainRepo)).toBeNull();
});
it.runIf(process.platform === 'win32')('is not a nested repository of its own; a different repository below still is', () => {
const lowerMain = lowerDrive(mainRepo);
const lowerWorktree = lowerDrive(worktree);
expect(nestedRepositoryBelow(lowerWorktree, lowerMain)).toBeNull();
expect(nestedRepositoryBelow(worktree, lowerMain)).toBeNull();
expect(nestedRepositoryBelow(lowerWorktree, mainRepo)).toBeNull();
expect(nestedRepositoryBelow(lowerDrive(embedded), lowerMain)?.relPath).toBe('embedded');
});
});
/**
* 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
+29 -4
View File
@@ -98,12 +98,13 @@ export function detectWorktreeIndexMismatch(
if (!worktreeRoot) return null;
const resolvedIndexRoot = realpath(indexRoot);
if (worktreeRoot === resolvedIndexRoot) return null;
if (sameDirectory(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;
const indexWorktreeRoot = gitWorktreeRoot(resolvedIndexRoot);
if (!indexWorktreeRoot || !sameDirectory(indexWorktreeRoot, resolvedIndexRoot)) return null;
// Don't flag a nested repo (submodule / embedded clone) that `indexRoot`'s
// index ALREADY covers: indexing a super-repo descends into its submodules
@@ -117,7 +118,7 @@ export function detectWorktreeIndexMismatch(
// not — so suppress only when the two clearly differ. (#1031, #1033)
const worktreeCommon = gitCommonDir(worktreeRoot);
const indexCommon = gitCommonDir(resolvedIndexRoot);
if (worktreeCommon && indexCommon && worktreeCommon !== indexCommon) return null;
if (worktreeCommon && indexCommon && !sameDirectory(worktreeCommon, indexCommon)) return null;
return { worktreeRoot, indexRoot: resolvedIndexRoot };
}
@@ -162,7 +163,9 @@ export function nestedRepositoryBelow(startPath: string, indexRoot: string): Nes
if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
const repoCommon = gitCommonDir(repoRoot);
if (!repoCommon || repoCommon === gitCommonDir(realpath(indexRoot))) return null;
if (!repoCommon) return null;
const indexCommon = gitCommonDir(realpath(indexRoot));
if (indexCommon && sameDirectory(repoCommon, indexCommon)) return null;
return { root: repoRoot, relPath: rel.split(path.sep).join('/') };
}
@@ -182,6 +185,28 @@ function nativeRealpath(p: string): string {
}
}
/**
* Whether two paths name one directory, however each is spelled. For
* comparing only: the paths this module returns keep their own spelling.
*
* git answers with the on-disk spelling (a `subst` drive and links followed,
* the case stored on disk), while callers hand in theirs: VS Code and Cursor
* pass a lowercase drive letter, a `subst` drive is a second name for a
* folder, and NTFS ignores case. Compared as strings, one directory looked
* like two, so a linked worktree spelled unlike git lost the borrowed-index
* warning and was refused as a repository of its own (#2517). The identity is
* `canonicalProjectRoot`'s — the native realpath, lowercased on Windows —
* falling back to the JS realpath where the native call fails.
*/
function sameDirectory(a: string, b: string): boolean {
return a === b || directoryIdentity(a) === directoryIdentity(b);
}
function directoryIdentity(p: string): string {
const real = nativeRealpath(p);
return process.platform === 'win32' ? real.toLowerCase() : real;
}
/** One-line-per-fact warning describing a detected mismatch. */
export function worktreeMismatchWarning(m: WorktreeIndexMismatch): string {
return (