GitNexus/gitnexus/test/unit/status-content-drift.test.ts
mengkaka 79543c8f83
feat(storage): add configurable index storage and content retention tiers (#3060)
* 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>
2026-09-12 20:31:55 +00:00

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);
});
});