GitNexus/gitnexus/test/unit/list-status-branch.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

449 lines
16 KiB
TypeScript

/**
* Unit tests for branch-aware `list` and `status` rendering (#2106).
*
* The data layer (repo-manager / git) is mocked so these assert only the
* console rendering: branch indexes appear when present, single-branch output
* is unchanged, and `status` reflects the checked-out branch.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
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.9',
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, branch?: string, resolvedStoragePath?: string) => ({
storagePath: resolvedStoragePath ?? `${repoPath}/.gitnexus`,
lbugPath: branch
? `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/branches/${branch}/lbug`
: `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/lbug`,
metaPath: branch
? `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/branches/${branch}/meta.json`
: `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/meta.json`,
})),
loadMeta: vi.fn(),
hasKuzuIndex: vi.fn().mockResolvedValue(false),
readRegistryStrict: vi.fn(),
resolveRegistryEntry: vi.fn(),
RegistryNotFoundError: class RegistryNotFoundError extends Error {},
RegistryAmbiguousTargetError: class RegistryAmbiguousTargetError extends Error {},
}));
vi.mock('../../src/core/analyzer-identity.js', () => ({
resolveAnalyzerRunnerIdentity: vi.fn(() => runnerIdentity),
analyzerRunnerIdentitiesEqual: vi.fn(
(indexedIdentity: unknown, currentIdentity: unknown) => indexedIdentity === currentIdentity,
),
}));
vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/storage/storage-resolver.js')>()),
requireStoragePath: vi.fn().mockResolvedValue('/repo/.gitnexus'),
requireRegisteredStoragePath: vi.fn().mockResolvedValue('/repo/.gitnexus'),
}));
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([]),
}));
import { listCommand } from '../../src/cli/list.js';
import { statusCommand } from '../../src/cli/status.js';
import {
listRegisteredRepos,
loadMeta,
readRegistryStrict,
resolveRegistryEntry,
} from '../../src/storage/repo-manager.js';
import { getCurrentBranch, getCurrentCommit, isWorkingTreeDirty } from '../../src/storage/git.js';
import {
requireRegisteredStoragePath,
requireStoragePath,
STATUS_STORAGE_REQUIREMENTS,
StorageRequirementError,
} from '../../src/storage/storage-resolver.js';
let logSpy: ReturnType<typeof vi.spyOn>;
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
beforeEach(() => {
vi.clearAllMocks();
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
(loadMeta as any).mockResolvedValue({
repoPath: '/repo',
lastCommit: 'headsha0',
indexedAt: '2026-06-10T12:00:00.000Z',
branch: 'main',
runnerIdentity,
scopeExtractionReceipt: 1 as const,
});
});
describe('list branch rendering (#2106)', () => {
it('renders the primary branch and a Branch indexes section', async () => {
(listRegisteredRepos as any).mockResolvedValue([
{
name: 'multi',
path: '/repo',
storagePath: '/repo/.gitnexus',
indexedAt: '2026-06-10T12:00:00.000Z',
lastCommit: 'aaaaaaa1',
branch: 'main',
branches: [
{ branch: 'feature/x', indexedAt: '2026-06-10T13:00:00.000Z', lastCommit: 'bbbbbbb2' },
],
stats: { files: 1, nodes: 2, edges: 3 },
},
]);
await listCommand();
const out = output();
expect(out).toContain('Branch: main');
expect(out).toContain('Branch indexes:');
expect(out).toContain('feature/x');
expect(out).toContain('bbbbbbb'); // 7-char short commit of bbbbbbb2
});
it('single-branch entry renders no branch lines (unchanged)', async () => {
(listRegisteredRepos as any).mockResolvedValue([
{
name: 'solo',
path: '/solo',
storagePath: '/solo/.gitnexus',
indexedAt: '2026-06-10T12:00:00.000Z',
lastCommit: 'ccccccc3',
stats: { files: 1, nodes: 1, edges: 1 },
},
]);
await listCommand();
const out = output();
expect(out).not.toContain('Branch:');
expect(out).not.toContain('Branch indexes:');
});
});
describe('status branch rendering (#2106)', () => {
const baseRepo = {
repoPath: '/repo',
storagePath: '/repo/.gitnexus',
lbugPath: '/repo/.gitnexus/lbug',
metaPath: '/repo/.gitnexus/meta.json',
meta: {
repoPath: '/repo',
lastCommit: 'headsha0',
indexedAt: '2026-06-10T12:00:00.000Z',
branch: 'main',
runnerIdentity,
scopeExtractionReceipt: 1 as const,
},
};
it.each([false, true])('uses the strict registry reader for status --repo%s', async (json) => {
const corrupt = new Error('registry is corrupt: entry 0 is malformed');
vi.mocked(readRegistryStrict).mockRejectedValueOnce(corrupt);
await expect(statusCommand({ repo: 'demo', json })).rejects.toBe(corrupt);
expect(resolveRegistryEntry).not.toHaveBeenCalled();
});
it('resolves the Git root before checking the default storage path', async () => {
(loadMeta as any).mockResolvedValue(baseRepo.meta);
await statusCommand({ json: true });
expect(requireStoragePath).toHaveBeenCalledWith('/repo', STATUS_STORAGE_REQUIREMENTS);
});
it('checks the exact registry storage path for status --repo', async () => {
const entry = { path: '/repo', storagePath: '/external/repo-slot' };
(readRegistryStrict as any).mockResolvedValue([entry]);
(resolveRegistryEntry as any).mockReturnValue(entry);
(requireRegisteredStoragePath as any).mockResolvedValue(entry.storagePath);
(loadMeta as any).mockResolvedValue({ ...baseRepo.meta, repoPath: entry.path });
await statusCommand({ repo: 'repo', json: true });
expect(requireRegisteredStoragePath).toHaveBeenCalledWith(entry, STATUS_STORAGE_REQUIREMENTS);
expect(JSON.parse(output())).toMatchObject({ storagePath: entry.storagePath });
});
it.each(['symbol', 'none'] as const)(
'status --repo reports source-unavailable when checkout exists but contentRetention is %s',
async (contentRetention) => {
const checkout = await fs.mkdtemp(path.join(os.tmpdir(), 'gnx-status-src-'));
try {
const entry = { path: checkout, storagePath: '/external/repo-slot' };
(readRegistryStrict as any).mockResolvedValue([entry]);
(resolveRegistryEntry as any).mockReturnValue(entry);
(requireRegisteredStoragePath as any).mockResolvedValue(entry.storagePath);
(loadMeta as any).mockResolvedValue({
...baseRepo.meta,
repoPath: entry.path,
contentRetention,
});
await statusCommand({ repo: 'repo', json: true });
expect(JSON.parse(output())).toMatchObject({
sourceAvailable: false,
status: 'source-unavailable',
index: { contentRetention },
});
} finally {
await fs.rm(checkout, { recursive: true, force: true });
}
},
);
it('status --repo reports sourceAvailable when checkout exists and retention is full', async () => {
const checkout = await fs.mkdtemp(path.join(os.tmpdir(), 'gnx-status-full-'));
try {
const entry = { path: checkout, storagePath: '/external/repo-slot' };
(readRegistryStrict as any).mockResolvedValue([entry]);
(resolveRegistryEntry as any).mockReturnValue(entry);
(requireRegisteredStoragePath as any).mockResolvedValue(entry.storagePath);
(loadMeta as any).mockResolvedValue({
...baseRepo.meta,
repoPath: entry.path,
contentRetention: 'full',
});
await statusCommand({ repo: 'repo', json: true });
expect(JSON.parse(output())).toMatchObject({
sourceAvailable: true,
status: 'registered',
});
} finally {
await fs.rm(checkout, { recursive: true, force: true });
}
});
it('rejects a foreign registered storage path instead of treating it as an index', async () => {
const entry = { path: '/repo', storagePath: '/external/repo-slot' };
const inspection = {
repoPath: entry.path,
storagePath: entry.storagePath,
state: 'foreign' as const,
hasCodeIndexDB: true,
};
(readRegistryStrict as any).mockResolvedValue([entry]);
(resolveRegistryEntry as any).mockReturnValue(entry);
(requireRegisteredStoragePath as any).mockRejectedValue(
new StorageRequirementError(inspection, STATUS_STORAGE_REQUIREMENTS),
);
await expect(statusCommand({ repo: 'repo', json: true })).rejects.toBeInstanceOf(
StorageRequirementError,
);
});
it('renders indexed and current typed runner receipts for exact comparison', async () => {
(loadMeta as any).mockResolvedValue(baseRepo.meta);
(getCurrentBranch as any).mockReturnValue('main');
(getCurrentCommit as any).mockReturnValue('headsha0');
await statusCommand();
const out = output();
expect(out).toContain(`Indexed analyzer runner identity: ${JSON.stringify(runnerIdentity)}`);
expect(out).toContain(`Current analyzer runner identity: ${JSON.stringify(runnerIdentity)}`);
});
it('renders stable machine-readable provenance with --json', async () => {
(loadMeta as any).mockResolvedValue(baseRepo.meta);
(getCurrentBranch as any).mockReturnValue('main');
(getCurrentCommit as any).mockReturnValue('headsha0');
await statusCommand({ json: true });
const parsed = JSON.parse(output());
expect(parsed).toMatchObject({
schemaVersion: 1,
repository: '/repo',
index: { commit: 'headsha0', runnerIdentity, runnerIdentityStatus: 'current' },
current: { commit: 'headsha0', runnerIdentity },
status: 'up-to-date',
});
});
it('reports a dirty working tree as stale in --json even when the commit matches', async () => {
(loadMeta as any).mockResolvedValue(baseRepo.meta);
(getCurrentBranch as any).mockReturnValue('main');
(getCurrentCommit as any).mockReturnValue('headsha0');
(isWorkingTreeDirty as any).mockReturnValueOnce(true);
await statusCommand({ json: true });
expect(JSON.parse(output())).toMatchObject({
index: { commit: 'headsha0' },
current: { commit: 'headsha0' },
status: 'stale',
});
});
it('reports a dirty working tree as stale in the human output at a matching commit', async () => {
(loadMeta as any).mockResolvedValue(baseRepo.meta);
(getCurrentBranch as any).mockReturnValue('main');
(getCurrentCommit as any).mockReturnValue('headsha0');
(isWorkingTreeDirty as any).mockReturnValueOnce(true);
await statusCommand();
expect(output()).not.toContain('up-to-date');
});
it('never certifies dirty or checkpointed metadata and reports stable incomplete reasons', async () => {
(loadMeta as any).mockResolvedValue({
...baseRepo.meta,
incrementalInProgress: { startedAt: 1, toWriteCount: 2 },
embeddingCheckpoint: {
at: '2026-07-18T00:00:00.000Z',
nodesProcessed: 1,
totalNodes: 2,
chunksProcessed: 1,
model: 'fixture',
dimensions: 3,
provider: 'local',
},
});
(getCurrentBranch as any).mockReturnValue('main');
(getCurrentCommit as any).mockReturnValue('headsha0');
await statusCommand({ json: true });
expect(JSON.parse(output())).toMatchObject({
index: {
incompleteReasons: ['incremental-in-progress', 'embedding-checkpoint-pending'],
runnerIdentityStatus: 'current',
},
status: 'stale',
});
});
it('treats an older runner receipt schema as stale at the same commit', async () => {
(loadMeta as any).mockResolvedValue({
...baseRepo.meta,
runnerIdentity: { ...runnerIdentity, schemaVersion: 1 },
});
(getCurrentBranch as any).mockReturnValue('main');
(getCurrentCommit as any).mockReturnValue('headsha0');
await statusCommand({ json: true });
expect(JSON.parse(output())).toMatchObject({
index: { runnerIdentityStatus: 'stale-or-unknown' },
status: 'stale',
});
});
it('shows the current branch and up-to-date on the primary', async () => {
(loadMeta as any).mockResolvedValue(baseRepo.meta);
(getCurrentBranch as any).mockReturnValue('main');
(getCurrentCommit as any).mockReturnValue('headsha0');
await statusCommand();
const out = output();
expect(out).toContain('Branch: main');
expect(out).toContain('up-to-date');
});
it('falls through to the workspace index when the branch has no pinned index (#2354)', async () => {
(loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce(null); // feature/y has no pinned index
(getCurrentBranch as any).mockReturnValue('feature/y');
(getCurrentCommit as any).mockReturnValue('headsha9');
await statusCommand();
const out = output();
expect(out).toContain('Branch: feature/y');
// The flat workspace index (last analyzed on main) is reported, with the
// commit comparison saying it lags this branch's tree.
expect(out).toContain("Workspace index: last analyzed on 'main'");
expect(out).toContain('stale');
});
it('same-commit branch flip reports up-to-date against the workspace index (#2354)', async () => {
(loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce(null); // feature/y has no pinned index
(getCurrentBranch as any).mockReturnValue('feature/y');
(getCurrentCommit as any).mockReturnValue('headsha0'); // same commit as flat meta
await statusCommand();
const out = output();
expect(out).toContain("Workspace index: last analyzed on 'main'");
expect(out).toContain('up-to-date');
});
it('compares against the branch index when the current branch has one', async () => {
(loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce({
repoPath: '/repo',
lastCommit: 'zzzzsha0',
indexedAt: '2026-06-10T14:00:00.000Z',
branch: 'feature/z',
runnerIdentity,
scopeExtractionReceipt: 1,
});
(getCurrentBranch as any).mockReturnValue('feature/z');
(getCurrentCommit as any).mockReturnValue('zzzzsha0');
await statusCommand();
const out = output();
expect(out).toContain('Branch: feature/z');
expect(out).toContain('up-to-date');
});
it('shows detached HEAD and compares against the flat index', async () => {
(loadMeta as any).mockResolvedValue(baseRepo.meta);
(getCurrentBranch as any).mockReturnValue(null); // detached
(getCurrentCommit as any).mockReturnValue('headsha0');
await statusCommand();
const out = output();
expect(out).toContain('(detached HEAD)');
expect(out).toContain('up-to-date');
});
it('reports stale when the branch index is behind the branch tip', async () => {
(loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce({
repoPath: '/repo',
lastCommit: 'oldsha00',
indexedAt: '2026-06-10T14:00:00.000Z',
branch: 'feature/z',
});
(getCurrentBranch as any).mockReturnValue('feature/z');
(getCurrentCommit as any).mockReturnValue('newsha99'); // moved past the index
await statusCommand();
const out = output();
expect(out).toContain('Branch: feature/z');
expect(out).toContain('stale');
});
});