/** * Integration Tests: Context Resource Staleness Fix (#2438) * * End-to-end flow with real git and real registry/meta I/O. */ import { execFileSync } from 'child_process'; import { mkdirSync, writeFileSync } from 'fs'; import path from 'path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createTempDir } from '../helpers/test-db.js'; import { initGitRepo } from '../helpers/temp-git-repo.js'; import type { RepoMeta } from '../../src/storage/repo-manager.js'; import { getStoragePaths, registerRepo, saveMeta } from '../../src/storage/repo-manager.js'; import { LocalBackend } from '../../src/mcp/local/local-backend.js'; import { readResource } from '../../src/mcp/resources.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── function runGit(repoPath: string, ...args: string[]): string { try { return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8' }).trim(); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`git ${args.join(' ')} failed in ${repoPath}: ${message}`); } } /** * Persist index metadata and register the repo in the global registry. * `saveMeta` runs before `registerRepo` so registry validation can immediately * see a readable metadata file for this entry. * @param repoPath Absolute path to the git repository under test. * @param storagePath Absolute path to the repo metadata directory. * @param meta Metadata snapshot to write to gitnexus.json and registry. * @param repoName Registry alias used by LocalBackend for this repo. */ async function seedIndexedRepo( repoPath: string, storagePath: string, meta: RepoMeta, repoName: string = 'test-repo', ): Promise { // Registry validation accepts only an owned storage directory that contains // the index path. The context resource itself reads metadata only, so a // minimal placeholder directory is sufficient for this fixture. mkdirSync(path.join(storagePath, 'lbug'), { recursive: true }); await saveMeta(storagePath, meta); await registerRepo(repoPath, meta, { name: repoName }); } // ─── Tests ─────────────────────────────────────────────────────────────────── describe('context resource freshness — out-of-process analyze (#2438)', () => { let tmpDir: Awaited>; let repoPath: string; let storagePath: string; let savedHome: string | undefined; beforeEach(async () => { tmpDir = await createTempDir('gnx-ctx-staleness-'); repoPath = tmpDir.dbPath; // Isolate the global registry from the developer's real ~/.gitnexus savedHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = path.join(repoPath, '.gitnexus-home'); storagePath = getStoragePaths(repoPath).storagePath; initGitRepo(repoPath, { name: 'GitNexus Test', email: 'gitnexus@example.com' }); }); afterEach(async () => { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpDir.cleanup(); }); it('clears the staleness banner after out-of-process analyze updates gitnexus.json', async () => { // ── STEP 1: Repository HEAD advances from C1 to C2 ─────────────────────── writeFileSync(path.join(repoPath, 'a.ts'), 'export const a = 1;\n'); runGit(repoPath, 'add', 'a.ts'); runGit(repoPath, 'commit', '-m', 'c1'); const c1 = runGit(repoPath, 'rev-parse', 'HEAD'); writeFileSync(path.join(repoPath, 'b.ts'), 'export const b = 2;\n'); runGit(repoPath, 'add', 'b.ts'); runGit(repoPath, 'commit', '-m', 'c2'); const c2 = runGit(repoPath, 'rev-parse', 'HEAD'); const oldStats = { files: 100, nodes: 500, processes: 10 }; const freshStats = { files: 120, nodes: 600, processes: 12 }; await seedIndexedRepo(repoPath, storagePath, { repoPath, lastCommit: c1, indexedAt: '2024-01-01T00:00:00Z', stats: oldStats, }); const backend = new LocalBackend(); await backend.init(); // C1 is stale against current HEAD (C2) const resultBefore = await readResource(`gitnexus://repo/test-repo/context`, backend); expect(resultBefore).toContain('staleness:'); expect(resultBefore).toContain('1 commit behind'); // Stats reflect the old (C1-era) values from gitnexus.json expect(resultBefore).toContain('files: 100'); expect(resultBefore).toContain('symbols: 500'); // ── STEP 3: Out-of-process analyze runs, updates gitnexus.json to C2 ─── // The MCP server (LocalBackend) is NOT restarted — this is the bug scenario. await saveMeta(storagePath, { repoPath, lastCommit: c2, indexedAt: new Date().toISOString(), stats: freshStats, }); // ── STEP 4: Re-read context resource WITHOUT restarting the MCP server ── const resultAfter = await readResource(`gitnexus://repo/test-repo/context`, backend); // Staleness banner MUST be gone — the fresh gitnexus.json has lastCommit = C2 expect(resultAfter).not.toContain('staleness:'); // Stats MUST be fresh — taken from the updated gitnexus.json expect(resultAfter).toContain('files: 120'); expect(resultAfter).toContain('symbols: 600'); expect(resultAfter).toContain('processes: 12'); }); it('shows stale banner before analyze and clears it after — full reproduce sequence', async () => { writeFileSync(path.join(repoPath, 'a.ts'), 'export const a = 1;\n'); runGit(repoPath, 'add', 'a.ts'); runGit(repoPath, 'commit', '-m', 'c1'); writeFileSync(path.join(repoPath, 'b.ts'), 'export const b = 2;\n'); runGit(repoPath, 'add', 'b.ts'); runGit(repoPath, 'commit', '-m', 'c2'); const c2 = runGit(repoPath, 'rev-parse', 'HEAD'); const stats = { files: 50, nodes: 200, processes: 5 }; await seedIndexedRepo(repoPath, storagePath, { repoPath, lastCommit: c2, indexedAt: '2024-01-01T00:00:00Z', stats, }); const backend = new LocalBackend(); await backend.init(); // Pre-analyze: registry/meta are seeded at current HEAD (C2), so not stale const r1 = await readResource(`gitnexus://repo/test-repo/context`, backend); expect(r1).not.toContain('staleness:'); // New commit arrives; indexed commit (C2) is stale writeFileSync(path.join(repoPath, 'c.ts'), 'export const c = 3;\n'); runGit(repoPath, 'add', 'c.ts'); runGit(repoPath, 'commit', '-m', 'c3'); const c3 = runGit(repoPath, 'rev-parse', 'HEAD'); const r2 = await readResource(`gitnexus://repo/test-repo/context`, backend); expect(r2).toContain('staleness:'); expect(r2).toContain('1 commit behind'); // Out-of-process analyze --index-only completes; gitnexus.json updated to C3 const freshStats = { files: 60, nodes: 250, processes: 7 }; await saveMeta(storagePath, { repoPath, lastCommit: c3, indexedAt: new Date().toISOString(), stats: freshStats, }); // Third read — MCP server still running, but context must reflect fresh state const r3 = await readResource(`gitnexus://repo/test-repo/context`, backend); expect(r3).not.toContain('staleness:'); // banner cleared expect(r3).toContain('files: 60'); // fresh stats expect(r3).toContain('symbols: 250'); expect(r3).toContain('processes: 7'); }); it('stat fields absent in disk meta fall through to cached context stats', async () => { writeFileSync(path.join(repoPath, 'a.ts'), 'export const a = 1;\n'); runGit(repoPath, 'add', 'a.ts'); runGit(repoPath, 'commit', '-m', 'c1'); const c1 = runGit(repoPath, 'rev-parse', 'HEAD'); const oldStats = { files: 77, nodes: 333, processes: 4 }; await seedIndexedRepo(repoPath, storagePath, { repoPath, lastCommit: c1, indexedAt: '2024-01-01T00:00:00Z', stats: oldStats, }); const backend = new LocalBackend(); await backend.init(); // Overwrite disk meta with NO stats (simulating an older/partial file) await saveMeta(storagePath, { repoPath, lastCommit: c1, indexedAt: new Date().toISOString(), }); const result = await readResource(`gitnexus://repo/test-repo/context`, backend); // Falls back to cached context stats (from registry entry) expect(result).toContain('files: 77'); expect(result).toContain('symbols: 333'); expect(result).toContain('processes: 4'); }); });