diff --git a/CHANGELOG.md b/CHANGELOG.md index 379ecdb85..653a28cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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) diff --git a/__tests__/mcp-nested-repo-projectpath.test.ts b/__tests__/mcp-nested-repo-projectpath.test.ts index 6dc27cc07..aecc93df6 100644 --- a/__tests__/mcp-nested-repo-projectpath.test.ts +++ b/__tests__/mcp-nested-repo-projectpath.test.ts @@ -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 }); diff --git a/__tests__/worktree-detection.test.ts b/__tests__/worktree-detection.test.ts index 0696e804f..a5d9be2c9 100644 --- a/__tests__/worktree-detection.test.ts +++ b/__tests__/worktree-detection.test.ts @@ -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 diff --git a/src/sync/worktree.ts b/src/sync/worktree.ts index a648b9333..a3b241076 100644 --- a/src/sync/worktree.ts +++ b/src/sync/worktree.ts @@ -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 (