mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
* feat(storage): add configurable index storage and content retention tiers Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH, GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in main's FTS skip, embed-session, and help-text updates. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Keep legacy registry rows on the local storage fallback, resolve symlinks before the destructive-path guard, and align hook lookup with CLI branch slugs, branch-slot metadata, and longest-path match. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Only list swept upload directories after a successful removal so callers cannot treat a permission or transient rm failure as gone. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Document that getStoragePath may consult registered storage while this module still does not mutate the global registry. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(storage): close review findings for external indexes and retention Re-inspect ownership under the analyze lock, fail-closed when the registry file is missing, and keep skip-git hook discovery plus retention fields on HTTP/MCP list surfaces. /api/file stays 410 unless contentRetention is full. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * Address PR review feedback (#3060) Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix macOS hook test expecting realpath'd registry paths. resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that. * Address gitnexus-check warnings on hook install docs and slot tests. The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory. * Align the HTTP catalog source-scan with skippable resolveRepo validation. resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true. * Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear. Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time. * Settle bridge stamps before writing so CI size/mtime matches stay stable. LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches. * Type the settled bridge stat as fs.Stats so tsc does not see bigint. Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI. * Keep the bridge mtime stamp exact so same-size swaps still fail the pair check. Co-authored-by: Cursor <cursoragent@cursor.com> * Wrap the bridge stamp predicate so prettier --check stays green. Co-authored-by: Cursor <cursoragent@cursor.com> * Require a quiet interval before stamping a settled bridge file. Co-authored-by: Cursor <cursoragent@cursor.com> * Reuse shared storage and settle helpers instead of local copies. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
210 lines
8.6 KiB
TypeScript
210 lines
8.6 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
// 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<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;
|
|
|
|
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');
|
|
});
|
|
});
|