mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
f16b28a4c5
3 changed files with 354 additions and 6 deletions
|
|
@ -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)');
|
||||
|
|
|
|||
207
gitnexus/test/integration/context-resource-staleness.test.ts
Normal file
207
gitnexus/test/integration/context-resource-staleness.test.ts
Normal file
|
|
@ -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<void> {
|
||||
await saveMeta(storagePath, meta);
|
||||
await registerRepo(repoPath, meta, { name: repoName });
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('context resource freshness — out-of-process analyze (#2438)', () => {
|
||||
let tmpDir: Awaited<ReturnType<typeof createTempDir>>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof import('../../src/storage/repo-manager.js')>();
|
||||
return { ...actual, loadMeta: loadMetaMock };
|
||||
});
|
||||
|
||||
// ─── Minimal mock backend ──────────────────────────────────────────
|
||||
|
||||
function createMockBackend(overrides: Partial<Record<string, any>> = {}): any {
|
||||
|
|
@ -25,6 +33,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',
|
||||
},
|
||||
),
|
||||
|
|
@ -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:');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue