mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +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>
338 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|