From 13848338dd76e44ba4b1cdff6450a9dc70ee6ca7 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 22:26:28 +0700 Subject: [PATCH] fix(mcp): refresh context metadata per request Closes #118 --- gitnexus/src/mcp/resources.ts | 12 ++- .../context-resource-staleness.test.ts | 78 +++++++++++++++++++ gitnexus/test/unit/resources.test.ts | 66 +++++++++++++++- 3 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 gitnexus/test/integration/context-resource-staleness.test.ts diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index 48cd89b57f..6ea1ac8162 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,12 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro return 'error: No codebase loaded. Run: gitnexus analyze'; } + // Repo handles are cached, while analysis can refresh metadata out of process. + const freshMeta = await loadMeta(repo.storagePath).catch(() => null); + // Check staleness 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 }; @@ -327,9 +331,9 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro 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: ${freshMeta?.stats?.files ?? context.stats.fileCount}`); + lines.push(` symbols: ${freshMeta?.stats?.nodes ?? context.stats.functionCount}`); + lines.push(` processes: ${freshMeta?.stats?.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 0000000000..0176748988 --- /dev/null +++ b/gitnexus/test/integration/context-resource-staleness.test.ts @@ -0,0 +1,78 @@ +import { execFileSync } from 'child_process'; +import { writeFileSync } from 'fs'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { readResource } from '../../src/mcp/resources.js'; +import type { RepoMeta } from '../../src/storage/repo-manager.js'; +import { getStoragePaths, registerRepo, saveMeta } from '../../src/storage/repo-manager.js'; +import { createTempDir } from '../helpers/test-db.js'; + +function git(repoPath: string, ...args: string[]): string { + return execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' }).trim(); +} + +describe('context resource after an out-of-process analysis', () => { + let temp: Awaited>; + let repoPath: string; + let storagePath: string; + let previousHome: string | undefined; + + beforeEach(async () => { + temp = await createTempDir('gitnexus-context-resource-'); + repoPath = temp.dbPath; + previousHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = path.join(repoPath, '.registry'); + storagePath = getStoragePaths(repoPath).storagePath; + + git(repoPath, 'init'); + git(repoPath, 'config', 'user.name', 'GitNexus Test'); + git(repoPath, 'config', 'user.email', 'gitnexus@example.com'); + }); + + afterEach(async () => { + if (previousHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = previousHome; + await temp.cleanup(); + }); + + it('refreshes the staleness banner and stats without restarting MCP', async () => { + writeFileSync(path.join(repoPath, 'a.ts'), 'export const a = 1;\n'); + git(repoPath, 'add', 'a.ts'); + git(repoPath, 'commit', '-m', 'first'); + const firstCommit = git(repoPath, 'rev-parse', 'HEAD'); + + writeFileSync(path.join(repoPath, 'b.ts'), 'export const b = 2;\n'); + git(repoPath, 'add', 'b.ts'); + git(repoPath, 'commit', '-m', 'second'); + const secondCommit = git(repoPath, 'rev-parse', 'HEAD'); + + const initial: RepoMeta = { + repoPath, + lastCommit: firstCommit, + indexedAt: '2026-07-14T00:00:00.000Z', + stats: { files: 1, nodes: 1, processes: 1 }, + }; + await saveMeta(storagePath, initial); + await registerRepo(repoPath, initial, { name: 'context-fixture' }); + + const backend = new LocalBackend(); + await backend.init(); + const before = await readResource('gitnexus://repo/context-fixture/context', backend); + expect(before).toContain('1 commit behind'); + expect(before).toContain('files: 1'); + + await saveMeta(storagePath, { + repoPath, + lastCommit: secondCommit, + indexedAt: '2026-07-14T01:00:00.000Z', + stats: { files: 2, nodes: 2, processes: 2 }, + }); + + const after = await readResource('gitnexus://repo/context-fixture/context', backend); + expect(after).not.toContain('staleness:'); + expect(after).toContain('files: 2'); + expect(after).toContain('symbols: 2'); + expect(after).toContain('processes: 2'); + }); +}); diff --git a/gitnexus/test/unit/resources.test.ts b/gitnexus/test/unit/resources.test.ts index a0ea2348f1..c7bb8ad0cf 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 { beforeEach, describe, it, expect, vi } from 'vitest'; import { getResourceDefinitions, getResourceTemplates, @@ -16,6 +16,12 @@ import { readResource, } from '../../src/mcp/resources.js'; +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 +31,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 +402,59 @@ describe('readResource', () => { expect(result).not.toMatch(/gitnexus_/); }); }); + +describe('context resource metadata freshness', () => { + const context = { + projectName: 'test-project', + stats: { fileCount: 100, functionCount: 500, communityCount: 3, processCount: 10 }, + }; + + beforeEach(() => { + loadMetaMock.mockReset(); + loadMetaMock.mockResolvedValue(null); + }); + + it('uses fresh disk stats after repository authorization succeeds', async () => { + loadMetaMock.mockResolvedValue({ + repoPath: '/tmp/test-repo', + lastCommit: 'fresh-head', + indexedAt: '2026-07-14T00:00:00.000Z', + stats: { files: 250, nodes: 1500, processes: 25 }, + }); + const backend = createMockBackend({ context }); + + const result = await readResource('gitnexus://repo/test-project/context', backend); + + expect(backend.resolveRepo).toHaveBeenCalledWith('test-project'); + expect(loadMetaMock).toHaveBeenCalledWith('/tmp/test-repo/.gitnexus'); + expect(result).toContain('files: 250'); + expect(result).toContain('symbols: 1500'); + expect(result).toContain('processes: 25'); + }); + + it('falls back to cached stats when disk metadata is missing or unreadable', async () => { + const backend = createMockBackend({ context }); + + const missing = await readResource('gitnexus://repo/test-project/context', backend); + loadMetaMock.mockRejectedValueOnce(new Error('EACCES')); + const unreadable = await readResource('gitnexus://repo/test-project/context', backend); + + for (const result of [missing, unreadable]) { + expect(result).toContain('files: 100'); + expect(result).toContain('symbols: 500'); + expect(result).toContain('processes: 10'); + } + }); + + it('does not read metadata when repository authorization fails', async () => { + const backend = createMockBackend({ + resolveRepo: vi.fn().mockRejectedValue(new Error('Repository is not allowed')), + context, + }); + + await expect(readResource('gitnexus://repo/denied/context', backend)).rejects.toThrow( + 'Repository is not allowed', + ); + expect(loadMetaMock).not.toHaveBeenCalled(); + }); +});