GitNexus/gitnexus/test/unit/index-repo-command.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

338 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import path from 'node:path';
const { StorageRequirementError } = vi.hoisted(() => {
class StorageRequirementError extends Error {
constructor(
public inspection: {
repoPath: string;
storagePath: string;
state: string;
hasCodeIndexDB: boolean;
},
public requirements: { allowedStates: readonly string[]; requireCodeIndexDB?: boolean },
) {
super('storage requirement failed');
this.name = 'StorageRequirementError';
}
}
return { StorageRequirementError };
});
const mockAccess = vi.fn();
const mockGetStoragePaths = vi.fn();
const mockLoadMeta = vi.fn();
const mockSaveMeta = vi.fn();
const mockRegisterRepo = vi.fn();
const mockEnsureGitNexusIgnored = vi.fn();
const mockGetGitRoot = vi.fn();
const mockIsGitRepo = vi.fn();
const mockRequireStoragePath = vi.fn();
const mockGetIndexStorageRequirements = vi.fn((force: boolean) => ({
allowedStates: force ? ['owned', 'unowned', 'foreign'] : ['owned'],
requireCodeIndexDB: true,
}));
vi.mock('fs/promises', () => ({
default: {
access: mockAccess,
},
}));
vi.mock('../../src/storage/repo-manager.js', () => ({
getStoragePaths: mockGetStoragePaths,
INDEX_METADATA_FILE: 'gitnexus.json',
loadMeta: mockLoadMeta,
saveMeta: mockSaveMeta,
registerRepo: mockRegisterRepo,
ensureGitNexusIgnored: mockEnsureGitNexusIgnored,
}));
vi.mock('../../src/storage/git.js', () => ({
getGitRoot: mockGetGitRoot,
isGitRepo: mockIsGitRepo,
// `index-repo.ts` calls `getRemoteUrl` to backfill `remoteUrl` on
// older `.gitnexus/meta.json` files. The unit tests don't care
// about the remote URL, so a static `undefined` keeps behaviour
// identical to the pre-feature path.
getRemoteUrl: vi.fn().mockReturnValue(undefined),
}));
vi.mock('../../src/storage/storage-resolver.js', () => ({
getIndexStorageRequirements: mockGetIndexStorageRequirements,
requireStoragePath: mockRequireStoragePath,
StorageRequirementError,
}));
describe('indexCommand', () => {
const resolvedRepo = path.resolve('/repo');
const resolvedOutside = path.resolve('/outside/path');
const indexRequirements = { allowedStates: ['owned'] as const, requireCodeIndexDB: true };
const storageFailure = (
state: 'empty' | 'owned' | 'unowned',
hasCodeIndexDB: boolean,
): StorageRequirementError =>
new StorageRequirementError(
{
repoPath: resolvedRepo,
storagePath: `${resolvedRepo}/.gitnexus`,
state,
hasCodeIndexDB,
},
indexRequirements,
);
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
process.exitCode = undefined;
mockRequireStoragePath.mockReset();
mockRequireStoragePath.mockResolvedValue(`${resolvedRepo}/.gitnexus`);
mockGetStoragePaths.mockImplementation((repoPath: string) => ({
storagePath: `${repoPath}/.gitnexus`,
lbugPath: `${repoPath}/.gitnexus/lbug`,
metaPath: `${repoPath}/.gitnexus/gitnexus.json`,
}));
mockLoadMeta.mockResolvedValue({
repoPath: resolvedRepo,
lastCommit: 'abc123',
indexedAt: '2026-03-20T00:00:00.000Z',
stats: { nodes: 10, edges: 20 },
});
mockSaveMeta.mockResolvedValue(undefined);
mockAccess.mockResolvedValue(undefined);
mockEnsureGitNexusIgnored.mockResolvedValue(undefined);
mockGetGitRoot.mockReturnValue(resolvedRepo);
mockIsGitRepo.mockReturnValue(true);
});
it('fails when target path is not a git repository', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockIsGitRepo.mockReturnValue(false);
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(['/outside/path']);
expect(mockRegisterRepo).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
expect(logSpy).toHaveBeenCalledWith(` Not a git repository: ${resolvedOutside}`);
});
it('fails when no metadata or LadybugDB index exists', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockRequireStoragePath.mockRejectedValueOnce(storageFailure('empty', false));
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(['/repo']);
expect(mockRegisterRepo).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
expect(logSpy).toHaveBeenCalledWith(
` Expected gitnexus.json, .gitnexus/meta.json, or LadybugDB at: ${resolvedRepo}/.gitnexus`,
);
});
it('fails when lbug database does not exist', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockRequireStoragePath.mockRejectedValueOnce(storageFailure('owned', false));
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(['/repo']);
expect(mockRegisterRepo).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
expect(logSpy).toHaveBeenCalledWith(' Index exists but contains no LadybugDB database.');
});
it('fails when meta.json is missing and --force is not set', async () => {
mockLoadMeta.mockResolvedValue(null);
mockRequireStoragePath.mockRejectedValueOnce(storageFailure('unowned', true));
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(['/repo']);
expect(mockRegisterRepo).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it('registers with minimal metadata when meta is missing and --force is set', async () => {
mockLoadMeta.mockResolvedValue(null);
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(['/repo'], { force: true });
expect(mockRegisterRepo).toHaveBeenCalledTimes(1);
expect(mockSaveMeta).toHaveBeenCalledWith(
`${resolvedRepo}/.gitnexus`,
expect.objectContaining({ repoPath: resolvedRepo, lastCommit: '' }),
);
expect(mockRegisterRepo).toHaveBeenCalledWith(
resolvedRepo,
expect.objectContaining({
repoPath: resolvedRepo,
lastCommit: '',
}),
{ storagePath: `${resolvedRepo}/.gitnexus` },
);
expect(process.exitCode).toBeUndefined();
});
it('rewrites foreign local gitnexus.json ownership on --force adopt', async () => {
const foreignRepo = path.resolve('/other-repo');
mockLoadMeta.mockResolvedValue({
repoPath: foreignRepo,
storagePath: `${foreignRepo}/.gitnexus`,
lastCommit: 'abc123',
indexedAt: '2026-03-20T00:00:00.000Z',
stats: { nodes: 10, edges: 20 },
});
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(['/repo'], { force: true });
expect(mockSaveMeta).toHaveBeenCalledWith(
`${resolvedRepo}/.gitnexus`,
expect.objectContaining({
repoPath: resolvedRepo,
storagePath: `${resolvedRepo}/.gitnexus`,
lastCommit: 'abc123',
}),
);
expect(mockRegisterRepo).toHaveBeenCalledWith(
resolvedRepo,
expect.objectContaining({
repoPath: resolvedRepo,
storagePath: `${resolvedRepo}/.gitnexus`,
}),
{ storagePath: `${resolvedRepo}/.gitnexus` },
);
expect(process.exitCode).toBeUndefined();
});
it('registers with --force when LadybugDB exists but metadata is missing', async () => {
mockLoadMeta.mockResolvedValue(null);
mockAccess.mockImplementation(async (targetPath: string) => {
if (targetPath === `${resolvedRepo}/.gitnexus/lbug`) return undefined;
if (targetPath.includes('/.gitnexus/')) throw new Error(`missing ${targetPath}`);
return undefined;
});
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(['/repo'], { force: true });
expect(mockRegisterRepo).toHaveBeenCalledTimes(1);
expect(mockSaveMeta).toHaveBeenCalledWith(
`${resolvedRepo}/.gitnexus`,
expect.objectContaining({ repoPath: resolvedRepo, lastCommit: '' }),
);
expect(mockRegisterRepo).toHaveBeenCalledWith(
resolvedRepo,
expect.objectContaining({
repoPath: resolvedRepo,
lastCommit: '',
}),
{ storagePath: `${resolvedRepo}/.gitnexus` },
);
expect(process.exitCode).toBeUndefined();
});
it('fails without --force when LadybugDB exists but metadata is missing', async () => {
mockLoadMeta.mockResolvedValue(null);
mockRequireStoragePath.mockRejectedValueOnce(storageFailure('unowned', true));
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(['/repo']);
expect(mockRegisterRepo).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it('registers successfully with existing metadata', async () => {
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(['/repo']);
expect(mockRegisterRepo).toHaveBeenCalledTimes(1);
expect(mockRegisterRepo).toHaveBeenCalledWith(
resolvedRepo,
expect.objectContaining({ repoPath: resolvedRepo }),
{ storagePath: `${resolvedRepo}/.gitnexus` },
);
expect(mockEnsureGitNexusIgnored).toHaveBeenCalledTimes(1);
expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith(
resolvedRepo,
`${resolvedRepo}/.gitnexus`,
);
expect(process.exitCode).toBeUndefined();
});
it('registers non-git path when --allow-non-git is set', async () => {
mockIsGitRepo.mockReturnValue(false);
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(['/outside/path'], { allowNonGit: true });
expect(mockRegisterRepo).toHaveBeenCalledTimes(1);
expect(process.exitCode).toBeUndefined();
});
it('fails when called with no path and cwd is not inside a git repo', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetGitRoot.mockReturnValue(null);
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(); // no args
expect(mockRegisterRepo).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
expect(logSpy).toHaveBeenCalledWith(' Not inside a git repository, try to run git init\n');
});
it('registers from cwd when no path is provided', async () => {
// getGitRoot already mocked to return resolvedRepo in beforeEach
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(); // no args
expect(mockRegisterRepo).toHaveBeenCalledWith(
resolvedRepo,
expect.objectContaining({ repoPath: resolvedRepo }),
{ storagePath: `${resolvedRepo}/.gitnexus` },
);
expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith(
resolvedRepo,
`${resolvedRepo}/.gitnexus`,
);
expect(process.exitCode).toBeUndefined();
});
it('fails when multiple path parts do not resolve to a single existing path', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const ambiguousPath = path.resolve('/repo /other');
mockAccess.mockImplementation(async (targetPath: string) => {
if (targetPath === ambiguousPath) {
throw new Error('missing combined path');
}
return undefined;
});
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(['/repo', '/other']);
expect(mockRegisterRepo).not.toHaveBeenCalled();
expect(mockEnsureGitNexusIgnored).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
expect(logSpy).toHaveBeenCalledWith(' The `index` command accepts a single path only.');
});
it('prints node and edge stats after registration', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { indexCommand } = await import('../../src/cli/index-repo.js');
await indexCommand(['/repo']);
expect(logSpy).toHaveBeenCalledWith(' Repository registered: repo');
expect(logSpy).toHaveBeenCalledWith(' 10 nodes | 20 edges');
});
});