diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index 48cd89b57..839139a81 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -7,6 +7,7 @@ import type { LocalBackend } from './local/local-backend.js'; import { checkStaleness } from './staleness.js'; +import { loadMeta } from '../storage/repo-manager.js'; export interface ResourceDefinition { uri: string; @@ -311,9 +312,16 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro return 'error: No codebase loaded. Run: gitnexus analyze'; } - // Check staleness + // Read fresh metadata from disk on every context resource read to avoid showing + // a stale staleness banner or outdated stats after an out-of-process + // `analyze --index-only` refresh. The RepoHandle is cached in-memory and only + // refreshes on registry misses, so its lastCommit/stats can lag behind the + // on-disk state (#2438). Mirrors the ensureInitialized hot-swap pattern. + const freshMeta = await loadMeta(repo.storagePath).catch(() => null); + + // Check staleness using the current on-disk lastCommit (not the cached handle) const repoPath = repo.repoPath; - const lastCommit = repo.lastCommit || 'HEAD'; + const lastCommit = freshMeta?.lastCommit ?? repo.lastCommit ?? 'HEAD'; const staleness = repoPath ? checkStaleness(repoPath, lastCommit) : { isStale: false, commitsBehind: 0 }; @@ -325,11 +333,13 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro lines.push(`staleness: "${staleness.hint}"`); } + // Use fresh stats from disk meta when available; fall back to cached context + const freshStats = freshMeta?.stats; lines.push(''); lines.push('stats:'); - lines.push(` files: ${context.stats.fileCount}`); - lines.push(` symbols: ${context.stats.functionCount}`); - lines.push(` processes: ${context.stats.processCount}`); + lines.push(` files: ${freshStats?.files ?? context.stats.fileCount}`); + lines.push(` symbols: ${freshStats?.nodes ?? context.stats.functionCount}`); + lines.push(` processes: ${freshStats?.processes ?? context.stats.processCount}`); lines.push(''); lines.push('tools_available:'); lines.push(' - query: Process-grouped code intelligence (execution flows related to a concept)'); diff --git a/gitnexus/test/integration/context-resource-staleness.test.ts b/gitnexus/test/integration/context-resource-staleness.test.ts new file mode 100644 index 000000000..537a4ae90 --- /dev/null +++ b/gitnexus/test/integration/context-resource-staleness.test.ts @@ -0,0 +1,207 @@ +/** + * 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 { writeFileSync } from 'fs'; +import path from 'path'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createTempDir } from '../helpers/test-db.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 { + 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; + + runGit(repoPath, 'init'); + runGit(repoPath, 'config', 'user.name', 'GitNexus Test'); + runGit(repoPath, 'config', 'user.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'); + }); +}); diff --git a/gitnexus/test/unit/resources.test.ts b/gitnexus/test/unit/resources.test.ts index a0ea2348f..4879c2d14 100644 --- a/gitnexus/test/unit/resources.test.ts +++ b/gitnexus/test/unit/resources.test.ts @@ -8,7 +8,7 @@ * - Error handling for invalid URIs * - Resource handlers with mocked backend */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { getResourceDefinitions, getResourceTemplates, @@ -16,6 +16,14 @@ import { readResource, } from '../../src/mcp/resources.js'; +// Mock loadMeta so getContextResource doesn't hit the filesystem (#2438 fix). +// Default: returns null (simulates no on-disk meta — falls back to cached handle). +const { loadMetaMock } = vi.hoisted(() => ({ loadMetaMock: vi.fn().mockResolvedValue(null) })); +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadMeta: loadMetaMock }; +}); + // ─── Minimal mock backend ────────────────────────────────────────── function createMockBackend(overrides: Partial> = {}): any { @@ -25,6 +33,8 @@ function createMockBackend(overrides: Partial> = {}): any { overrides.resolvedRepo ?? { name: 'test-repo', repoPath: '/tmp/test-repo', + storagePath: '/tmp/test-repo/.gitnexus', + lbugPath: '/tmp/test-repo/.gitnexus/lbug', lastCommit: 'abc1234', }, ), @@ -394,3 +404,124 @@ describe('readResource', () => { expect(result).not.toMatch(/gitnexus_/); }); }); + +// ─── Context resource freshness (#2438) ───────────────────────────────────── +// +// After an out-of-process `analyze --index-only` refresh, the RepoHandle cached +// by LocalBackend is stale (lastCommit and stats come from the registry snapshot +// taken at init time). getContextResource must read from disk on every call so +// the staleness banner and stats always reflect the actual on-disk state. + +describe('context resource freshness after out-of-process analyze (#2438)', () => { + beforeEach(() => { + loadMetaMock.mockReset(); + loadMetaMock.mockResolvedValue(null); // default: no fresh meta + }); + + const CONTEXT = { + projectName: 'test-project', + stats: { fileCount: 100, functionCount: 500, communityCount: 3, processCount: 10 }, + }; + + it('uses fresh lastCommit from disk meta for staleness check', async () => { + // Simulate: the cached handle has an old commit, but the on-disk meta has + // been updated to the current HEAD by an out-of-process analyze. + loadMetaMock.mockResolvedValue({ + repoPath: '/tmp/test-repo', + lastCommit: 'fresh-head-commit', + indexedAt: new Date().toISOString(), + stats: { files: 200, nodes: 1000, processes: 20 }, + }); + + const backend = createMockBackend({ + resolvedRepo: { + name: 'test-project', + repoPath: '/tmp/test-repo', + storagePath: '/tmp/test-repo/.gitnexus', + lbugPath: '/tmp/test-repo/.gitnexus/lbug', + lastCommit: 'old-stale-commit', // stale cached value + }, + context: CONTEXT, + }); + + // loadMeta is called with storagePath, not lbugPath + await readResource('gitnexus://repo/test-project/context', backend); + expect(loadMetaMock).toHaveBeenCalledWith('/tmp/test-repo/.gitnexus'); + }); + + it('shows fresh stats from disk meta after out-of-process analyze', async () => { + // Cached stats are stale (100 files, 500 symbols); disk meta has refreshed stats + loadMetaMock.mockResolvedValue({ + repoPath: '/tmp/test-repo', + lastCommit: 'current-head', + indexedAt: new Date().toISOString(), + stats: { files: 250, nodes: 1500, processes: 25 }, + }); + + const backend = createMockBackend({ + context: CONTEXT, // stale: fileCount:100, functionCount:500 + }); + + const result = await readResource('gitnexus://repo/test-project/context', backend); + // Fresh stats from disk override the cached context stats + expect(result).toContain('files: 250'); + expect(result).toContain('symbols: 1500'); + expect(result).toContain('processes: 25'); + // Stale cached values should NOT appear + expect(result).not.toContain('files: 100'); + expect(result).not.toContain('symbols: 500'); + }); + + it('falls back to cached stats when loadMeta returns null', async () => { + // loadMeta returns null (e.g. pre-analyze state or missing gitnexus.json) + loadMetaMock.mockResolvedValue(null); + + const backend = createMockBackend({ context: CONTEXT }); + const result = await readResource('gitnexus://repo/test-project/context', backend); + // Must still show the cached stats (no crash, no blank output) + expect(result).toContain('files: 100'); + expect(result).toContain('symbols: 500'); + expect(result).toContain('processes: 10'); + }); + + it('falls back to cached lastCommit when loadMeta throws', async () => { + // loadMeta throws (e.g. permissions error) + loadMetaMock.mockRejectedValue(new Error('EACCES: permission denied')); + + const backend = createMockBackend({ context: CONTEXT }); + // Should not throw — falls back gracefully + const result = await readResource('gitnexus://repo/test-project/context', backend); + expect(result).toContain('test-project'); + expect(result).toContain('stats:'); + }); + + it('does not show staleness banner when fresh lastCommit matches HEAD', async () => { + // After analyze completes, lastCommit in meta equals HEAD → no stale banner. + // We simulate this by returning a fresh meta; checkStaleness will be called + // with the fresh commit but the /tmp path has no git repo so it returns safe. + loadMetaMock.mockResolvedValue({ + repoPath: '/tmp/test-repo', + lastCommit: 'head-after-analyze', + indexedAt: new Date().toISOString(), + stats: { files: 200, nodes: 1000, processes: 20 }, + }); + + const backend = createMockBackend({ + resolvedRepo: { + name: 'test-project', + repoPath: '/tmp/test-repo', + storagePath: '/tmp/test-repo/.gitnexus', + lbugPath: '/tmp/test-repo/.gitnexus/lbug', + lastCommit: 'old-stale-commit', // stale, would show banner if used + }, + context: CONTEXT, + }); + + const result = await readResource('gitnexus://repo/test-project/context', backend); + // With a non-git path checkStaleness errors → no banner even with stale commit. + // What matters: the fresh commit was passed to checkStaleness, not the old one. + // (The staleness banner itself requires a live git repo, tested in integration.) + expect(result).toContain('test-project'); + expect(result).not.toContain('error:'); + }); +});