1
0

worktree-detection.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. /**
  2. * Git worktree index-mismatch detection (issue #155).
  3. *
  4. * A CodeGraph index is resolved by walking up to the nearest `.codegraph/`.
  5. * When a worktree is nested inside the main checkout, that walk reaches the
  6. * MAIN checkout's index and a query silently returns the main branch's code
  7. * instead of the worktree's. `detectWorktreeIndexMismatch` spots exactly this
  8. * case so callers can warn.
  9. *
  10. * These tests drive real `git` against real temp worktrees — no mocking — so
  11. * they exercise the same `git rev-parse --show-toplevel` behavior production
  12. * relies on.
  13. */
  14. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  15. import { execFileSync } from 'child_process';
  16. import * as fs from 'fs';
  17. import * as os from 'os';
  18. import * as path from 'path';
  19. import {
  20. detectWorktreeIndexMismatch,
  21. worktreeMismatchWarning,
  22. gitWorktreeRoot,
  23. } from '../src/sync/worktree';
  24. import CodeGraph from '../src/index';
  25. import { ToolHandler } from '../src/mcp/tools';
  26. function git(cwd: string, ...args: string[]): void {
  27. execFileSync('git', args, { cwd, stdio: ['ignore', 'ignore', 'ignore'] });
  28. }
  29. /** realpath so macOS /var → /private/var symlinking doesn't break equality. */
  30. function real(p: string): string {
  31. return fs.realpathSync(path.resolve(p));
  32. }
  33. describe('detectWorktreeIndexMismatch (issue #155)', () => {
  34. let mainRepo: string; // main checkout — owns the .codegraph index
  35. let worktree: string; // a linked worktree nested inside the main checkout
  36. let nonGit: string; // a directory outside any git repo
  37. beforeEach(() => {
  38. mainRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wt-main-'));
  39. nonGit = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wt-plain-'));
  40. git(mainRepo, 'init', '-q');
  41. git(mainRepo, 'config', 'user.email', 'test@example.com');
  42. git(mainRepo, 'config', 'user.name', 'Test');
  43. git(mainRepo, 'config', 'commit.gpgsign', 'false');
  44. fs.writeFileSync(path.join(mainRepo, 'README.md'), '# main\n');
  45. git(mainRepo, 'add', '.');
  46. git(mainRepo, 'commit', '-q', '-m', 'init');
  47. // Nest the worktree under the main checkout, mirroring tools that place
  48. // worktrees in (gitignored) subpaths like `.claude/worktrees/<name>/`.
  49. worktree = path.join(mainRepo, 'wt');
  50. git(mainRepo, 'worktree', 'add', '-q', '-b', 'feature', worktree);
  51. });
  52. afterEach(() => {
  53. try { git(mainRepo, 'worktree', 'remove', '--force', worktree); } catch { /* best effort */ }
  54. fs.rmSync(mainRepo, { recursive: true, force: true });
  55. fs.rmSync(nonGit, { recursive: true, force: true });
  56. });
  57. it('flags a worktree borrowing the main checkout index', () => {
  58. const m = detectWorktreeIndexMismatch(worktree, mainRepo);
  59. expect(m).not.toBeNull();
  60. expect(m!.worktreeRoot).toBe(real(worktree));
  61. expect(m!.indexRoot).toBe(real(mainRepo));
  62. });
  63. it('returns null when the index lives in the same working tree', () => {
  64. expect(detectWorktreeIndexMismatch(mainRepo, mainRepo)).toBeNull();
  65. expect(detectWorktreeIndexMismatch(worktree, worktree)).toBeNull();
  66. });
  67. it('returns null for a subdirectory of the same working tree', () => {
  68. const sub = path.join(mainRepo, 'src');
  69. fs.mkdirSync(sub);
  70. expect(detectWorktreeIndexMismatch(sub, mainRepo)).toBeNull();
  71. });
  72. it('returns null when startPath is not in a git repo', () => {
  73. expect(detectWorktreeIndexMismatch(nonGit, mainRepo)).toBeNull();
  74. });
  75. it('returns null when the index root is a plain (non-worktree) directory', () => {
  76. // startPath is a real worktree, but the index sits in an unrelated non-git
  77. // dir — that's "index in an ancestor", not "borrowed another worktree".
  78. expect(detectWorktreeIndexMismatch(worktree, nonGit)).toBeNull();
  79. });
  80. it('gitWorktreeRoot reports each tree distinctly', () => {
  81. expect(gitWorktreeRoot(worktree)).toBe(real(worktree));
  82. expect(gitWorktreeRoot(mainRepo)).toBe(real(mainRepo));
  83. expect(gitWorktreeRoot(nonGit)).toBeNull();
  84. });
  85. it('warning names both trees and the fix', () => {
  86. const msg = worktreeMismatchWarning(detectWorktreeIndexMismatch(worktree, mainRepo)!);
  87. expect(msg).toContain(real(worktree));
  88. expect(msg).toContain(real(mainRepo));
  89. expect(msg).toContain('codegraph init');
  90. });
  91. });
  92. /**
  93. * The detection above only helps if it reaches the agent. Agents call the read
  94. * tools (search/context/trace/…), almost never status — so the mismatch notice
  95. * has to ride on every read tool's result, not just status. These tests drive
  96. * the real `ToolHandler.execute` chokepoint against a real index whose default
  97. * project resolves UP from a nested worktree to the main checkout.
  98. */
  99. describe('worktree mismatch surfaces on hot read tools (issue #155)', () => {
  100. let mainRepo: string;
  101. let worktree: string;
  102. let cg: CodeGraph;
  103. let handler: ToolHandler;
  104. beforeEach(async () => {
  105. mainRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wt-tool-'));
  106. git(mainRepo, 'init', '-q');
  107. git(mainRepo, 'config', 'user.email', 'test@example.com');
  108. git(mainRepo, 'config', 'user.name', 'Test');
  109. git(mainRepo, 'config', 'commit.gpgsign', 'false');
  110. fs.mkdirSync(path.join(mainRepo, 'src'));
  111. fs.writeFileSync(path.join(mainRepo, 'src', 'a.ts'), 'export function mainOnly() { return 1; }\n');
  112. git(mainRepo, 'add', '.');
  113. git(mainRepo, 'commit', '-q', '-m', 'init');
  114. // The index lives in the MAIN checkout.
  115. cg = CodeGraph.initSync(mainRepo);
  116. await cg.indexAll();
  117. // Nested worktree, mirroring tools that place them under .claude/worktrees/<name>/.
  118. worktree = path.join(mainRepo, 'wt');
  119. git(mainRepo, 'worktree', 'add', '-q', '-b', 'feature', worktree);
  120. handler = new ToolHandler(cg);
  121. });
  122. afterEach(() => {
  123. try { cg.destroy(); } catch { /* best effort */ }
  124. try { git(mainRepo, 'worktree', 'remove', '--force', worktree); } catch { /* best effort */ }
  125. fs.rmSync(mainRepo, { recursive: true, force: true });
  126. });
  127. it('prefixes a compact notice on codegraph_search run from a nested worktree', async () => {
  128. handler.setDefaultProjectHint(worktree);
  129. const res = await handler.execute('codegraph_search', { query: 'mainOnly' });
  130. const text = res.content[0].text;
  131. expect(res.isError).toBeFalsy();
  132. expect(text).toContain('different git worktree');
  133. expect(text).toContain(real(worktree));
  134. expect(text).toContain('codegraph init');
  135. });
  136. it('does NOT prefix when the default project is the main checkout itself', async () => {
  137. handler.setDefaultProjectHint(mainRepo);
  138. const res = await handler.execute('codegraph_search', { query: 'mainOnly' });
  139. expect(res.content[0].text).not.toContain('different git worktree');
  140. });
  141. it('still shows the verbose warning on codegraph_status', async () => {
  142. handler.setDefaultProjectHint(worktree);
  143. const res = await handler.execute('codegraph_status', {});
  144. const text = res.content[0].text;
  145. expect(text).toContain('different git working tree');
  146. expect(text).toContain(real(worktree));
  147. });
  148. it('caches detection — a later tool call needs no further git spawn', async () => {
  149. handler.setDefaultProjectHint(worktree);
  150. // First call computes + caches the mismatch (this is the only git spawn).
  151. const first = await handler.execute('codegraph_search', { query: 'mainOnly' });
  152. expect(first.content[0].text).toContain('different git worktree');
  153. // Make git unreachable. A fresh detection would now return null (no notice);
  154. // the notice still appearing on a *different* tool proves it came from cache.
  155. const savedPath = process.env.PATH;
  156. process.env.PATH = '';
  157. try {
  158. const second = await handler.execute('codegraph_explore', { query: 'mainOnly' });
  159. expect(second.content[0].text).toContain('different git worktree');
  160. } finally {
  161. process.env.PATH = savedPath;
  162. }
  163. });
  164. });
  165. /**
  166. * A long-lived MCP server (the shared daemon) cached its worktree-mismatch
  167. * verdict keyed only by the start path, and that cache was cleared only on
  168. * shutdown. So once the server decided "this worktree borrows the main
  169. * checkout's index" — true while the worktree had no `.codegraph/` of its own —
  170. * the verdict was pinned for the daemon's whole life. After the worktree got
  171. * its own index (the resolved index root flipped from the main checkout to the
  172. * worktree itself), the CLI saw the worktree's index but the MCP server kept
  173. * emitting the stale false warning until a restart (issue #926).
  174. *
  175. * The verdict depends on BOTH the start path and the resolved index root, so it
  176. * must be cached under both — a changed index root has to invalidate it. This
  177. * drives the real `ToolHandler` worktree-notice path across exactly that change
  178. * (the resolved index root flips when the server's default project is re-opened
  179. * onto the worktree's own index), with no mocking.
  180. */
  181. describe('worktree mismatch verdict re-resolves when the index root changes (issue #926)', () => {
  182. let mainRepo: string;
  183. let worktree: string;
  184. let mainCg: CodeGraph;
  185. let worktreeCg: CodeGraph;
  186. let handler: ToolHandler;
  187. beforeEach(async () => {
  188. mainRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wt-926-'));
  189. git(mainRepo, 'init', '-q');
  190. git(mainRepo, 'config', 'user.email', 'test@example.com');
  191. git(mainRepo, 'config', 'user.name', 'Test');
  192. git(mainRepo, 'config', 'commit.gpgsign', 'false');
  193. fs.mkdirSync(path.join(mainRepo, 'src'));
  194. fs.writeFileSync(path.join(mainRepo, 'src', 'a.ts'), 'export function mainOnly() { return 1; }\n');
  195. git(mainRepo, 'add', '.');
  196. git(mainRepo, 'commit', '-q', '-m', 'init');
  197. // The long-lived server's default project starts as the MAIN checkout.
  198. mainCg = CodeGraph.initSync(mainRepo);
  199. await mainCg.indexAll();
  200. // Nested worktree that later gains its own index.
  201. worktree = path.join(mainRepo, 'wt');
  202. git(mainRepo, 'worktree', 'add', '-q', '-b', 'feature', worktree);
  203. worktreeCg = CodeGraph.initSync(worktree);
  204. await worktreeCg.indexAll();
  205. handler = new ToolHandler(mainCg);
  206. });
  207. afterEach(() => {
  208. try { mainCg.destroy(); } catch { /* best effort */ }
  209. try { worktreeCg.destroy(); } catch { /* best effort */ }
  210. try { git(mainRepo, 'worktree', 'remove', '--force', worktree); } catch { /* best effort */ }
  211. fs.rmSync(mainRepo, { recursive: true, force: true });
  212. });
  213. it('drops the stale "borrowed the main index" warning once the index root flips to the worktree', async () => {
  214. // The server runs from inside the worktree, default project = main checkout.
  215. handler.setDefaultProjectHint(worktree);
  216. // Phase 1: the index genuinely belongs to a different working tree (the main
  217. // checkout) → warn, and cache that verdict.
  218. const before = await handler.execute('codegraph_status', {});
  219. expect(before.content[0].text).toContain('different git working tree');
  220. expect(before.content[0].text).toContain(real(mainRepo));
  221. // Phase 2: the worktree's own index is now the server's default project
  222. // (engine re-open → setDefaultCodeGraph). The resolved index root for the
  223. // SAME start path flipped to the worktree itself, so the verdict must be
  224. // recomputed to "no mismatch" — not served stale from before.
  225. handler.setDefaultCodeGraph(worktreeCg);
  226. const after = await handler.execute('codegraph_status', {});
  227. expect(after.content[0].text).not.toContain('different git working tree');
  228. });
  229. });