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>
282 lines
9.4 KiB
TypeScript
282 lines
9.4 KiB
TypeScript
/**
|
|
* Unit Tests: `status` freshness verdict from per-file drift (#3077)
|
|
*
|
|
* The reported defect was a verdict nobody could clear: any modified or
|
|
* untracked file in the working tree — including files the index never reads —
|
|
* made `status` print "stale (re-run gitnexus analyze)", and running `analyze`
|
|
* left it unchanged. These tests pin the new decision order: the per-file
|
|
* comparison decides when it can run, and the repo-wide dirty flag survives
|
|
* only as the fallback for metadata written before `fileHashes` existed.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
const { runnerIdentity } = vi.hoisted(() => ({
|
|
runnerIdentity: {
|
|
schemaVersion: 4 as const,
|
|
runtime: {
|
|
executablePath: '/usr/bin/node',
|
|
version: 'v22.0.0',
|
|
platform: 'linux',
|
|
architecture: 'x64',
|
|
modulesAbi: '127',
|
|
libc: 'glibc:2.39',
|
|
},
|
|
cliVersion: '1.6.10',
|
|
invokedArtifact: { path: '/opt/gitnexus/dist/cli/index.js', digest: 'sha256:entry' },
|
|
build: {
|
|
kind: 'distribution' as const,
|
|
rootPath: '/opt/gitnexus/dist',
|
|
canonicalization: 'gitnexus-analyzer-build-v2' as const,
|
|
digest: 'sha256:build',
|
|
},
|
|
dependencyRuntime: {
|
|
manifestPath: '/opt/gitnexus/package.json',
|
|
lockfilePath: '/opt/package-lock.json',
|
|
canonicalization: 'gitnexus-analyzer-dependency-runtime-v4' as const,
|
|
packageCount: 42,
|
|
artifactCount: 12,
|
|
digest: 'sha256:dependencies',
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/storage/repo-manager.js', () => ({
|
|
listRegisteredRepos: vi.fn(),
|
|
getStoragePaths: vi.fn((repoPath: string) => ({
|
|
storagePath: `${repoPath}/.gitnexus`,
|
|
lbugPath: `${repoPath}/.gitnexus/lbug`,
|
|
metaPath: `${repoPath}/.gitnexus/meta.json`,
|
|
})),
|
|
loadMeta: vi.fn(),
|
|
hasKuzuIndex: vi.fn().mockResolvedValue(false),
|
|
}));
|
|
|
|
vi.mock('../../src/core/analyzer-identity.js', () => ({
|
|
resolveAnalyzerRunnerIdentity: vi.fn(() => runnerIdentity),
|
|
analyzerRunnerIdentitiesEqual: vi.fn((indexed: unknown, current: unknown) => indexed === current),
|
|
}));
|
|
|
|
vi.mock('../../src/storage/git.js', () => ({
|
|
isGitRepo: vi.fn().mockReturnValue(true),
|
|
getCurrentCommit: vi.fn().mockReturnValue('headsha0'),
|
|
getCurrentBranch: vi.fn().mockReturnValue('main'),
|
|
getGitRoot: vi.fn().mockReturnValue('/repo'),
|
|
isWorkingTreeDirty: vi.fn().mockReturnValue(false),
|
|
listWorkingTreeDirtyPaths: vi.fn().mockReturnValue([]),
|
|
}));
|
|
|
|
vi.mock('../../src/core/index-content-drift.js', () => ({
|
|
detectIndexContentDrift: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/storage/storage-resolver.js', () => ({
|
|
requireStoragePath: vi.fn().mockResolvedValue('/repo/.gitnexus'),
|
|
requireRegisteredStoragePath: vi.fn().mockResolvedValue('/repo/.gitnexus'),
|
|
STATUS_STORAGE_REQUIREMENTS: { allowedStates: ['owned'], requireCodeIndexDB: true },
|
|
StorageRequirementError: class StorageRequirementError extends Error {},
|
|
}));
|
|
|
|
import { statusCommand } from '../../src/cli/status.js';
|
|
import { setCliLanguage } from '../../src/cli/i18n/index.js';
|
|
import { loadMeta } from '../../src/storage/repo-manager.js';
|
|
import { getCurrentCommit, isWorkingTreeDirty } from '../../src/storage/git.js';
|
|
import { detectIndexContentDrift } from '../../src/core/index-content-drift.js';
|
|
|
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
|
|
const repoWithCoverage = {
|
|
repoPath: '/repo',
|
|
storagePath: '/repo/.gitnexus',
|
|
lbugPath: '/repo/.gitnexus/lbug',
|
|
metaPath: '/repo/.gitnexus/meta.json',
|
|
meta: {
|
|
repoPath: '/repo',
|
|
lastCommit: 'headsha0',
|
|
indexedAt: '2026-08-28T12:00:00.000Z',
|
|
branch: 'main',
|
|
runnerIdentity,
|
|
fileHashes: { 'a.js': 'sha-a' },
|
|
scopeExtractionReceipt: 1 as const,
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
(loadMeta as any).mockResolvedValue(repoWithCoverage.meta);
|
|
(getCurrentCommit as any).mockReturnValue('headsha0');
|
|
(isWorkingTreeDirty as any).mockReturnValue(false);
|
|
});
|
|
|
|
afterEach(() => {
|
|
setCliLanguage(null);
|
|
logSpy.mockRestore();
|
|
});
|
|
|
|
describe('status freshness from per-file drift (#3077)', () => {
|
|
it('is up-to-date when every covered file matches, despite a dirty working tree', async () => {
|
|
// The reported case: one modified file outside the index's coverage. The
|
|
// old repo-wide check called this stale and `analyze` could not clear it.
|
|
(isWorkingTreeDirty as any).mockReturnValue(true);
|
|
(detectIndexContentDrift as any).mockResolvedValue({ kind: 'current', coveredFileCount: 210 });
|
|
|
|
await statusCommand({ json: true });
|
|
|
|
expect(JSON.parse(output())).toMatchObject({
|
|
status: 'up-to-date',
|
|
contentDrift: { status: 'current', coveredFiles: 210 },
|
|
});
|
|
});
|
|
|
|
it('reports covered-file drift as stale and names the files', async () => {
|
|
(detectIndexContentDrift as any).mockResolvedValue({
|
|
kind: 'drifted',
|
|
changed: ['src/app.ts'],
|
|
added: [],
|
|
deleted: [],
|
|
});
|
|
|
|
await statusCommand();
|
|
|
|
const out = output();
|
|
expect(out).not.toContain('up-to-date');
|
|
expect(out).toContain('1 changed, 0 added, 0 deleted');
|
|
expect(out).toContain('changed: src/app.ts');
|
|
});
|
|
|
|
it('escapes control characters in drifted path names', async () => {
|
|
(detectIndexContentDrift as any).mockResolvedValue({
|
|
kind: 'drifted',
|
|
changed: ['src/\u001b[31mevil.ts'],
|
|
added: [],
|
|
deleted: [],
|
|
});
|
|
|
|
await statusCommand();
|
|
|
|
const out = output();
|
|
expect(out).toContain(JSON.stringify('src/\u001b[31mevil.ts'));
|
|
expect(out).not.toContain('\u001b[31m');
|
|
});
|
|
|
|
it('localizes overflow category labels in zh-CN', async () => {
|
|
setCliLanguage('zh-CN');
|
|
const changed = Array.from({ length: 12 }, (_, i) => `src/file-${i}.ts`);
|
|
(detectIndexContentDrift as any).mockResolvedValue({
|
|
kind: 'drifted',
|
|
changed,
|
|
added: [],
|
|
deleted: [],
|
|
});
|
|
|
|
await statusCommand();
|
|
|
|
const out = output();
|
|
expect(out).toContain('已修改: src/file-0.ts');
|
|
expect(out).toContain('另有 2 个 已修改');
|
|
expect(out).not.toMatch(/\bchanged\b/);
|
|
});
|
|
|
|
it('names a failed coverage scan in human output instead of falling back', async () => {
|
|
(detectIndexContentDrift as any).mockResolvedValue({
|
|
kind: 'unmeasurable',
|
|
reason: 'scan-failed',
|
|
});
|
|
|
|
await statusCommand();
|
|
|
|
const out = output();
|
|
expect(out).toContain('coverage scan failed');
|
|
expect(out).toContain('stale');
|
|
expect(out).not.toContain('fell back to the working-tree check');
|
|
});
|
|
|
|
it('exposes drift counts and a capped sample in --json', async () => {
|
|
const changed = Array.from({ length: 25 }, (_, i) => `src/file-${i}.ts`);
|
|
(detectIndexContentDrift as any).mockResolvedValue({
|
|
kind: 'drifted',
|
|
changed,
|
|
added: [],
|
|
deleted: [],
|
|
});
|
|
|
|
await statusCommand({ json: true });
|
|
|
|
const parsed = JSON.parse(output());
|
|
expect(parsed.status).toBe('stale');
|
|
expect(parsed.contentDrift.counts).toEqual({ changed: 25, added: 0, deleted: 0 });
|
|
expect(parsed.contentDrift.changed).toHaveLength(10);
|
|
expect(parsed.contentDrift.truncated).toEqual({
|
|
changed: true,
|
|
added: false,
|
|
deleted: false,
|
|
});
|
|
});
|
|
|
|
it('falls back to the working-tree check when coverage cannot be compared', async () => {
|
|
(detectIndexContentDrift as any).mockResolvedValue({
|
|
kind: 'unmeasurable',
|
|
reason: 'no-file-hashes',
|
|
});
|
|
(isWorkingTreeDirty as any).mockReturnValue(true);
|
|
|
|
await statusCommand({ json: true });
|
|
|
|
expect(JSON.parse(output())).toMatchObject({
|
|
status: 'stale',
|
|
contentDrift: { status: 'unmeasurable', reason: 'no-file-hashes' },
|
|
});
|
|
});
|
|
|
|
it('is stale when coverage cannot be compared because the scan failed', async () => {
|
|
(detectIndexContentDrift as any).mockResolvedValue({
|
|
kind: 'unmeasurable',
|
|
reason: 'scan-failed',
|
|
});
|
|
|
|
await statusCommand({ json: true });
|
|
|
|
expect(JSON.parse(output())).toMatchObject({
|
|
status: 'stale',
|
|
contentDrift: { status: 'unmeasurable', reason: 'scan-failed' },
|
|
});
|
|
});
|
|
|
|
it('is up-to-date on a clean tree when hashes are missing (legacy metadata)', async () => {
|
|
(detectIndexContentDrift as any).mockResolvedValue({
|
|
kind: 'unmeasurable',
|
|
reason: 'no-file-hashes',
|
|
});
|
|
|
|
await statusCommand({ json: true });
|
|
|
|
expect(JSON.parse(output())).toMatchObject({
|
|
status: 'up-to-date',
|
|
contentDrift: { status: 'unmeasurable', reason: 'no-file-hashes' },
|
|
});
|
|
});
|
|
|
|
it('skips the scan when the index is already stale on metadata alone', async () => {
|
|
// A moved HEAD is decided without paying for a repository-wide hash pass.
|
|
(getCurrentCommit as any).mockReturnValue('othersha');
|
|
|
|
await statusCommand({ json: true });
|
|
|
|
expect(detectIndexContentDrift).not.toHaveBeenCalled();
|
|
expect(JSON.parse(output())).toMatchObject({
|
|
status: 'stale',
|
|
contentDrift: { status: 'not-checked' },
|
|
});
|
|
});
|
|
|
|
it('replays persisted indexCoverage into the drift check', async () => {
|
|
const coverage = { maxFileSizeBytes: 1024 * 1024, dirtyPaths: ['a.js'] };
|
|
(loadMeta as any).mockResolvedValue({ ...repoWithCoverage.meta, indexCoverage: coverage });
|
|
(detectIndexContentDrift as any).mockResolvedValue({ kind: 'current', coveredFileCount: 1 });
|
|
|
|
await statusCommand({ json: true });
|
|
|
|
expect(detectIndexContentDrift).toHaveBeenCalledWith('/repo', { 'a.js': 'sha-a' }, coverage);
|
|
});
|
|
});
|