Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions gitnexus/src/mcp/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
Expand All @@ -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)');
Expand Down
78 changes: 78 additions & 0 deletions gitnexus/test/integration/context-resource-staleness.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createTempDir>>;
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');
});
});
66 changes: 65 additions & 1 deletion gitnexus/test/unit/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,20 @@
* - 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,
parseResourceUri,
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<typeof import('../../src/storage/repo-manager.js')>();
return { ...actual, loadMeta: loadMetaMock };
});

// ─── Minimal mock backend ──────────────────────────────────────────

function createMockBackend(overrides: Partial<Record<string, any>> = {}): any {
Expand All @@ -25,6 +31,8 @@ function createMockBackend(overrides: Partial<Record<string, any>> = {}): any {
overrides.resolvedRepo ?? {
name: 'test-repo',
repoPath: '/tmp/test-repo',
storagePath: '/tmp/test-repo/.gitnexus',
lbugPath: '/tmp/test-repo/.gitnexus/lbug',
lastCommit: 'abc1234',
},
),
Expand Down Expand Up @@ -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();
});
});
Loading