git-index-currency.test.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
  2. import * as cp from 'child_process';
  3. import * as fs from 'fs';
  4. import * as os from 'os';
  5. import * as path from 'path';
  6. import { CodeGraph } from '../src';
  7. // Wrap only I/O entry points for deterministic failure injection; all other
  8. // calls, files, parser work and SQLite remain real.
  9. vi.mock('child_process', async importOriginal => {
  10. const actual = await importOriginal<typeof import('child_process')>();
  11. return { ...actual, execFileSync: vi.fn(actual.execFileSync) };
  12. });
  13. vi.mock('fs', async importOriginal => {
  14. const actual = await importOriginal<typeof import('fs')>();
  15. return { ...actual, readFileSync: vi.fn(actual.readFileSync) };
  16. });
  17. describe('git index currency across commits and restores (#1829)', () => {
  18. let root: string;
  19. let cg: CodeGraph;
  20. const git = (...args: string[]) => cp.execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
  21. const write = (name: string, symbol: string) => fs.writeFileSync(path.join(root, name), `export function ${symbol}() { return 1; }\n`);
  22. const commit = () => { git('add', '-A'); git('commit', '-m', 'change'); return git('rev-parse', 'HEAD').trim(); };
  23. const metadata = () => (cg as any).queries;
  24. const symbols = (name: string) => cg.searchNodes(name).map(r => r.node.name);
  25. const clean = () => expect(cg.getChangedFiles()).toEqual({ added: [], modified: [], removed: [] });
  26. beforeEach(async () => {
  27. root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-git-currency-'));
  28. git('init'); git('config', 'user.email', 'test@example.invalid'); git('config', 'user.name', 'Test');
  29. fs.writeFileSync(path.join(root, '.gitignore'), '.codegraph/\n');
  30. write('source.ts', 'original'); commit();
  31. cg = CodeGraph.initSync(root);
  32. expect((await cg.indexAll()).success).toBe(true);
  33. });
  34. afterEach(() => { vi.restoreAllMocks(); cg?.close(); fs.rmSync(root, { recursive: true, force: true }); });
  35. it.each(['index', 'sync', 'scoped'] as const)('sees a restored indexed dirty edit after %s', async mode => {
  36. write('source.ts', 'dirtyVersion');
  37. if (mode === 'index') await cg.indexAll();
  38. else await cg.sync(mode === 'scoped' ? { paths: ['source.ts'] } : {});
  39. expect(symbols('dirtyVersion')).toContain('dirtyVersion');
  40. git('restore', 'source.ts');
  41. expect(git('status', '--porcelain')).toBe('');
  42. // Reopen proves that dirty candidates survive beyond one engine instance.
  43. cg.close(); cg = CodeGraph.openSync(root);
  44. expect(cg.getChangedFiles().modified).toEqual(['source.ts']);
  45. await cg.sync();
  46. expect(symbols('original')).toContain('original');
  47. expect(symbols('dirtyVersion')).not.toContain('dirtyVersion'); clean();
  48. });
  49. it.each(['index', 'sync'] as const)('does not claim a commit made during %s maintenance', async mode => {
  50. const oldHead = git('rev-parse', 'HEAD').trim();
  51. write('source.ts', 'firstEdit');
  52. const db = (cg as any).db;
  53. const maintain = db.runMaintenance.bind(db);
  54. const spy = vi.spyOn(db, 'runMaintenance').mockImplementationOnce(async () => {
  55. write('source.ts', 'lateCommit'); commit();
  56. return maintain();
  57. });
  58. if (mode === 'index') await cg.indexAll(); else await cg.sync();
  59. expect(spy).toHaveBeenCalledOnce();
  60. expect(metadata().getMetadata('indexed_at_commit')).toBe(oldHead);
  61. expect(cg.getChangedFiles().modified).toEqual(['source.ts']);
  62. await cg.sync(); expect(symbols('lateCommit')).toContain('lateCommit'); clean();
  63. });
  64. it('handles non-ASCII and quoted committed paths without git text escaping', async () => {
  65. const names = ['тест.ts', 'space name.ts'];
  66. if (process.platform !== 'win32') names.push('quote"name.ts');
  67. for (const file of names) write(file, 'newSymbol');
  68. commit();
  69. expect(cg.getChangedFiles().added.sort()).toEqual(names.sort());
  70. await cg.sync(); clean();
  71. fs.renameSync(path.join(root, 'тест.ts'), path.join(root, 'renamed.ts')); commit();
  72. expect(cg.getChangedFiles()).toEqual({ added: ['renamed.ts'], modified: [], removed: ['тест.ts'] });
  73. });
  74. it('falls back when git diff fails instead of claiming a clean index', () => {
  75. write('new.ts', 'newSymbol'); commit();
  76. const real = cp.execFileSync;
  77. let injected = 0;
  78. vi.spyOn(cp, 'execFileSync').mockImplementation(((file: string, args: string[], options: any) => {
  79. if (file === 'git' && args[0] === 'diff') { injected++; throw new Error('Injected git diff timeout'); }
  80. return real(file, args, options);
  81. }) as typeof cp.execFileSync);
  82. expect(cg.getChangedFiles().added).toEqual(['new.ts']);
  83. expect(injected).toBeGreaterThan(0);
  84. });
  85. it('does not advance the commit stamp after a scoped sync', async () => {
  86. const oldHead = metadata().getMetadata('indexed_at_commit');
  87. write('one.ts', 'one'); write('two.ts', 'two'); commit();
  88. await cg.sync({ paths: ['one.ts'] });
  89. expect(metadata().getMetadata('indexed_at_commit')).toBe(oldHead);
  90. expect(cg.getChangedFiles().added).toEqual(['two.ts']);
  91. await cg.sync(); clean();
  92. });
  93. it('retains a restored dirty path when a later scoped sync touches another file', async () => {
  94. write('source.ts', 'dirtyVersion'); await cg.sync();
  95. git('restore', 'source.ts'); write('other.ts', 'other');
  96. await cg.sync({ paths: ['other.ts'] });
  97. expect(cg.getChangedFiles()).toEqual({ added: [], modified: ['source.ts'], removed: [] });
  98. await cg.sync(); expect(symbols('original')).toContain('original'); clean();
  99. });
  100. it('does not call a recreated committed deletion removed when the current bytes match the DB', async () => {
  101. fs.unlinkSync(path.join(root, 'source.ts')); commit();
  102. write('source.ts', 'original');
  103. clean();
  104. write('source.ts', 'replacement');
  105. expect(cg.getChangedFiles()).toEqual({ added: [], modified: ['source.ts'], removed: [] });
  106. await cg.sync(); expect(symbols('replacement')).toContain('replacement'); clean();
  107. });
  108. it('retains deleted untracked files as candidates after they were indexed', async () => {
  109. write('untracked.ts', 'temporary'); await cg.sync();
  110. fs.unlinkSync(path.join(root, 'untracked.ts'));
  111. expect(cg.getChangedFiles().removed).toEqual(['untracked.ts']);
  112. await cg.sync(); expect(symbols('temporary')).not.toContain('temporary'); clean();
  113. });
  114. it('never advances freshness after a failed full index', async () => {
  115. write('new.ts', 'newSymbol'); commit();
  116. const controller = new AbortController(); controller.abort();
  117. expect((await cg.indexAll({ signal: controller.signal })).success).toBe(false);
  118. expect(cg.getChangedFiles().added).toEqual(['new.ts']);
  119. });
  120. it('keeps a committed path pending when sync cannot read it', async () => {
  121. write('new.ts', 'newSymbol'); commit();
  122. const real = fs.readFileSync;
  123. let injected = 0;
  124. vi.spyOn(fs, 'readFileSync').mockImplementation(((file: any, ...args: any[]) => {
  125. if (String(file) === path.join(root, 'new.ts')) { injected++; throw new Error('Injected transient read error'); }
  126. return (real as any)(file, ...args);
  127. }) as typeof fs.readFileSync);
  128. await cg.sync();
  129. expect(injected).toBeGreaterThan(0);
  130. expect(symbols('newSymbol')).not.toContain('newSymbol');
  131. vi.restoreAllMocks();
  132. expect(cg.getChangedFiles().added).toEqual(['new.ts']);
  133. await cg.sync(); clean();
  134. });
  135. });