/** * P1 Unit Tests: Repository Manager * * Tests: getStoragePath, getStoragePaths, readRegistry, registerRepo, unregisterRepo * Covers hardening fixes #29 (API key file permissions) and #30 (case-insensitive paths on Windows) */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'path'; import os from 'os'; import fs from 'fs/promises'; import { _captureLogger } from '../../src/core/logger.js'; import { getStoragePath, getStoragePaths, branchSlug, resolveBranchPlacement, saveMeta, loadMeta, hasIndex, loadRepo, findRepo, reconcileMetadataFiles, AnalysisNotFinalizedError, INDEX_METADATA_FILE, ensureGitNexusIgnored, readRegistry, loadCLIConfig, registerRepo, unregisterRepo, removeBranchIndex, adoptFlatBranchLabel, listRegisteredRepos, resolveRegistryEntry, findRegistryEntryByName, canonicalizePath, registryPathEquals, cloneDirBelongsToEntry, assertSafeStoragePath, RegistryNameCollisionError, RegistryNotFoundError, RegistryAmbiguousTargetError, UnsafeStoragePathError, type RegistryEntry, type RepoMeta, } from '../../src/storage/repo-manager.js'; import { acquireIndexLock } from '../../src/storage/index-lock.js'; import { parseRepoNameFromUrl, getInferredRepoName } from '../../src/storage/git.js'; import { execSync } from 'child_process'; import { createTempDir } from '../helpers/test-db.js'; // ─── getStoragePath ────────────────────────────────────────────────── describe('getStoragePath', () => { it('appends .gitnexus to resolved repo path', () => { const result = getStoragePath('/home/user/project'); expect(result).toContain('.gitnexus'); expect(path.basename(result)).toBe('.gitnexus'); }); it('resolves relative paths', () => { const result = getStoragePath('.'); // Should be an absolute path expect(path.isAbsolute(result)).toBe(true); }); }); // ─── getStoragePaths ───────────────────────────────────────────────── describe('getStoragePaths', () => { it('returns storagePath, lbugPath, metaPath', () => { const paths = getStoragePaths('/home/user/project'); expect(paths.storagePath).toContain('.gitnexus'); expect(paths.lbugPath).toContain('lbug'); expect(paths.metaPath).toContain('gitnexus.json'); }); it('all paths are under storagePath', () => { const paths = getStoragePaths('/home/user/project'); expect(paths.lbugPath.startsWith(paths.storagePath)).toBe(true); expect(paths.metaPath.startsWith(paths.storagePath)).toBe(true); }); // ─── #2106: branch-scoped paths ────────────────────────────────────── it('no/empty branch returns the flat layout (byte-identical)', () => { const flat = getStoragePaths('/home/user/project'); const explicitEmpty = getStoragePaths('/home/user/project', ''); expect(explicitEmpty.storagePath).toBe(flat.storagePath); expect(explicitEmpty.lbugPath).toBe(flat.lbugPath); expect(explicitEmpty.metaPath).toBe(flat.metaPath); expect(path.basename(flat.lbugPath)).toBe('lbug'); expect(path.dirname(flat.lbugPath)).toBe(flat.storagePath); }); it('a branch scopes lbug/meta under branches/ but keeps storagePath flat', () => { const flat = getStoragePaths('/home/user/project'); const branched = getStoragePaths('/home/user/project', 'feature/login'); // storagePath stays flat so shared content-addressed caches are shared. expect(branched.storagePath).toBe(flat.storagePath); const expectedDir = path.join(flat.storagePath, 'branches', branchSlug('feature/login')); expect(path.dirname(branched.lbugPath)).toBe(expectedDir); expect(path.dirname(branched.metaPath)).toBe(expectedDir); expect(path.basename(branched.lbugPath)).toBe('lbug'); expect(path.basename(branched.metaPath)).toBe('gitnexus.json'); }); }); // ─── Index ownership lookup ───────────────────────────────────────── describe('index ownership lookup', () => { let workspace: Awaited>; beforeEach(async () => { workspace = await createTempDir('gitnexus-index-ownership-'); }); afterEach(async () => { await workspace.cleanup(); }); it('does not resolve an indexed parent directory as a child Git repository', async () => { const childRepo = path.join(workspace.dbPath, 'child-repo'); const childSource = path.join(childRepo, 'src'); const parentStorage = getStoragePaths(workspace.dbPath).storagePath; await fs.mkdir(childSource, { recursive: true }); execSync('git init', { cwd: childRepo, stdio: 'ignore' }); await saveMeta(parentStorage, { repoPath: workspace.dbPath, storagePath: parentStorage, lastCommit: '', indexedAt: new Date(0).toISOString(), }); await expect(findRepo(childSource)).resolves.toBeNull(); }); it('rejects foreign metadata, while preserving metadata-only owned slots for clean', async () => { const repoPath = path.join(workspace.dbPath, 'repo'); const storagePath = getStoragePaths(repoPath).storagePath; await fs.mkdir(repoPath, { recursive: true }); await saveMeta(storagePath, { repoPath: path.join(workspace.dbPath, 'other-repo'), storagePath, lastCommit: '', indexedAt: new Date(0).toISOString(), }); await expect(loadRepo(repoPath)).resolves.toBeNull(); await expect(hasIndex(repoPath)).resolves.toBe(false); await saveMeta(storagePath, { repoPath, storagePath, lastCommit: '', indexedAt: new Date(0).toISOString(), }); await expect(loadRepo(repoPath)).resolves.toMatchObject({ repoPath, storagePath }); await expect(hasIndex(repoPath)).resolves.toBe(false); }); }); // ─── branchSlug (#2106) ────────────────────────────────────────────── describe('branchSlug (#2106)', () => { it('is deterministic for the same ref', () => { expect(branchSlug('feature/x')).toBe(branchSlug('feature/x')); }); it('avoids the feature/x vs feature_x collision', () => { expect(branchSlug('feature/x')).not.toBe(branchSlug('feature_x')); }); it('keeps a readable, filesystem-safe prefix', () => { const slug = branchSlug('feature/login'); expect(slug.startsWith('feature_login-')).toBe(true); // No path separators or unsafe characters. expect(slug).not.toContain('/'); expect(/^[A-Za-z0-9._-]+$/.test(slug)).toBe(true); }); it('contains adversarial / traversal branch names to a single safe segment', () => { // branchSlug feeds getStoragePaths directly on the server path (the MCP // `branch` param is NOT gated by validateBranchName), so containment must // hold for hostile inputs. A `..` substring inside a longer name is // harmless; only a standalone `..` segment traverses, and sanitizeRepoName // collapses separators so that can never happen. const base = path.join('/repo', '.gitnexus', 'branches'); for (const payload of ['../..', '../../etc/passwd', '..', '...', '/abs/path', 'x y']) { const slug = branchSlug(payload); expect(slug, payload).toMatch(/^[A-Za-z0-9._-]+$/); expect(slug, payload).not.toContain('/'); // path.join keeps the result a direct child of the branches dir. expect(path.dirname(path.join(base, slug)), payload).toBe(base); } }); }); // ─── resolveBranchPlacement (#2106 KTD2) ───────────────────────────── // Since #2354 only explicit `--branch` runs consult this (a plain analyze // always targets the flat workspace slot); these cases pin the explicit-run // contract. describe('resolveBranchPlacement (#2106)', () => { let tmpRepo: Awaited>; beforeEach(async () => { tmpRepo = await createTempDir('gitnexus-branch-placement-'); }); afterEach(async () => { await tmpRepo.cleanup(); }); const baseMeta = (branch?: string): RepoMeta => ({ repoPath: tmpRepo.dbPath, lastCommit: 'abc123', indexedAt: new Date(0).toISOString(), ...(branch ? { branch } : {}), }); it('null label (detached HEAD / non-git) → flat', async () => { expect(await resolveBranchPlacement(tmpRepo.dbPath, null)).toEqual({}); }); it('fresh repo with no flat index → flat (claims primary)', async () => { expect(await resolveBranchPlacement(tmpRepo.dbPath, 'main')).toEqual({}); }); it('legacy flat index without a recorded branch → flat (adopts)', async () => { const { storagePath } = getStoragePaths(tmpRepo.dbPath); await saveMeta(storagePath, baseMeta()); expect(await resolveBranchPlacement(tmpRepo.dbPath, 'feature')).toEqual({}); }); it('label equal to the recorded primary → flat', async () => { const { storagePath } = getStoragePaths(tmpRepo.dbPath); await saveMeta(storagePath, baseMeta('main')); expect(await resolveBranchPlacement(tmpRepo.dbPath, 'main')).toEqual({}); }); it('explicit label differing from the recorded flat branch → its own sub-directory', async () => { const { storagePath } = getStoragePaths(tmpRepo.dbPath); await saveMeta(storagePath, baseMeta('main')); expect(await resolveBranchPlacement(tmpRepo.dbPath, 'feature')).toEqual({ branch: 'feature' }); }); it('empty-string flatMeta.branch is not trusted → flat (R5)', async () => { const { storagePath } = getStoragePaths(tmpRepo.dbPath); await saveMeta(storagePath, { ...baseMeta(), branch: '' }); expect(await resolveBranchPlacement(tmpRepo.dbPath, 'feature')).toEqual({}); }); it('non-string flatMeta.branch (corrupt) is not trusted → flat (R5)', async () => { const { storagePath } = getStoragePaths(tmpRepo.dbPath); // Simulate a hand-edited/corrupt meta where branch is a number. await saveMeta(storagePath, { ...baseMeta(), branch: 42 as unknown as string }); expect(await resolveBranchPlacement(tmpRepo.dbPath, 'feature')).toEqual({}); }); }); // ─── saveMeta: dual-write + collision-safe tmp (review fix, F2/F8) ────── describe('saveMeta dual-write', () => { let tmpRepo: Awaited>; beforeEach(async () => { tmpRepo = await createTempDir('gitnexus-savemeta-dualwrite-'); }); afterEach(async () => { vi.restoreAllMocks(); await tmpRepo.cleanup(); }); const meta: RepoMeta = { repoPath: '/some/repo', lastCommit: 'abc123', indexedAt: new Date(0).toISOString(), }; it('writes identical content to gitnexus.json and legacy meta.json', async () => { const { storagePath } = getStoragePaths(tmpRepo.dbPath); await saveMeta(storagePath, meta); const primary = await fs.readFile(path.join(storagePath, 'gitnexus.json'), 'utf-8'); const legacy = await fs.readFile(path.join(storagePath, 'meta.json'), 'utf-8'); expect(JSON.parse(primary)).toEqual(meta); expect(JSON.parse(legacy)).toEqual(meta); }); it('round-trips scope extraction failure metadata through the production writer', async () => { const { storagePath } = getStoragePaths(tmpRepo.dbPath); const withFailures: RepoMeta = { ...meta, scopeExtractionReceipt: 1, scopeExtractionFailures: { total: 3, paths: ['src/a.ts', 'src/b.ts'], truncated: true, }, }; await saveMeta(storagePath, withFailures); expect(await loadMeta(storagePath)).toMatchObject({ scopeExtractionReceipt: 1, scopeExtractionFailures: { total: 3, paths: ['src/a.ts', 'src/b.ts'], truncated: true, }, }); }); it('leaves no stray tmp files behind after a successful write', async () => { const { storagePath } = getStoragePaths(tmpRepo.dbPath); await saveMeta(storagePath, meta); const entries = await fs.readdir(storagePath); expect(entries.filter((f) => f.includes('.tmp.'))).toEqual([]); }); it('two concurrent saveMeta calls on the same directory both succeed (no tmp-name collision)', async () => { const { storagePath } = getStoragePaths(tmpRepo.dbPath); const results = await Promise.allSettled([ saveMeta(storagePath, { ...meta, lastCommit: 'writerA' }), saveMeta(storagePath, { ...meta, lastCommit: 'writerB' }), ]); expect(results.map((r) => r.status)).toEqual(['fulfilled', 'fulfilled']); }); it('a legacy meta.json write failure is logged and does not fail the caller', async () => { const { storagePath } = getStoragePaths(tmpRepo.dbPath); const realOpen = fs.open; // Fail only the write whose tmp path is for the legacy file. vi.spyOn(fs, 'open').mockImplementation( async (filePath: Parameters[0], ...rest) => { if (String(filePath).includes(`${path.sep}meta.json.tmp.`)) { const err = new Error('simulated legacy-write failure') as NodeJS.ErrnoException; err.code = 'EACCES'; throw err; } return realOpen(filePath, ...rest); }, ); const cap = _captureLogger(); try { await expect(saveMeta(storagePath, meta)).resolves.not.toThrow(); const primary = await fs.readFile(path.join(storagePath, 'gitnexus.json'), 'utf-8'); expect(JSON.parse(primary)).toEqual(meta); await expect(fs.readFile(path.join(storagePath, 'meta.json'), 'utf-8')).rejects.toThrow(); expect( cap .records() .some((r) => r.level === 40 && String(r.msg ?? '').includes('legacy meta.json mirror')), ).toBe(true); } finally { cap.restore(); } }); }); // ─── AnalysisNotFinalizedError message names the checked file (F10) ───── describe('AnalysisNotFinalizedError diagnostic', () => { it("the 'meta' variant names the file assertAnalysisFinalized actually checks", () => { const err = new AnalysisNotFinalizedError( '/repo', '/repo/.gitnexus', 'meta', '/home/user/.gitnexus/registry.json', ); // Built from INDEX_METADATA_FILE so a future rename can't silently desync // the diagnostic from the check again (#1169 misdirection regression). expect(err.message).toContain(INDEX_METADATA_FILE); expect(err.message).toContain(path.join('/repo/.gitnexus', INDEX_METADATA_FILE)); }); }); // ─── loadMeta: strict legacy fallback (review fix, F4) ────────────────── describe('loadMeta strict fallback', () => { let tmpRepo: Awaited>; let storagePath: string; beforeEach(async () => { tmpRepo = await createTempDir('gitnexus-loadmeta-fallback-'); storagePath = getStoragePaths(tmpRepo.dbPath).storagePath; await fs.mkdir(storagePath, { recursive: true }); }); afterEach(async () => { vi.restoreAllMocks(); await tmpRepo.cleanup(); }); const meta: RepoMeta = { repoPath: '/some/repo', lastCommit: 'abc123', indexedAt: new Date(0).toISOString(), }; it('reads gitnexus.json directly when present', async () => { await fs.writeFile(path.join(storagePath, 'gitnexus.json'), JSON.stringify(meta)); await expect(loadMeta(storagePath)).resolves.toEqual(meta); }); it('falls back to legacy meta.json when gitnexus.json is absent (ENOENT)', async () => { await fs.writeFile(path.join(storagePath, 'meta.json'), JSON.stringify(meta)); await expect(loadMeta(storagePath)).resolves.toEqual(meta); }); it('returns null (NOT legacy content) when gitnexus.json is corrupt', async () => { // Pre-fix behavior silently resurrected the stale legacy baseline here, // masking the corruption; post-fix a corrupt primary forces the same safe // full-rebuild path a missing index would. await fs.writeFile(path.join(storagePath, 'gitnexus.json'), '{ not valid json'); await fs.writeFile(path.join(storagePath, 'meta.json'), JSON.stringify(meta)); await expect(loadMeta(storagePath)).resolves.toBeNull(); }); it('returns null (NOT legacy content) when gitnexus.json read fails with EACCES', async () => { await fs.writeFile(path.join(storagePath, 'gitnexus.json'), JSON.stringify(meta)); await fs.writeFile(path.join(storagePath, 'meta.json'), JSON.stringify(meta)); const realReadFile = fs.readFile; vi.spyOn(fs, 'readFile').mockImplementation(async (...args: Parameters) => { if (String(args[0]).endsWith('gitnexus.json')) { const err = new Error('permission denied') as NodeJS.ErrnoException; err.code = 'EACCES'; throw err; } return realReadFile(...args); }); await expect(loadMeta(storagePath)).resolves.toBeNull(); }); it('returns null when neither file exists', async () => { await expect(loadMeta(storagePath)).resolves.toBeNull(); }); }); // ─── reconcileMetadataFiles: stale-shadow regression (review fix, F3) ─── describe('reconcileMetadataFiles stale-shadow regression', () => { let tmpRepo: Awaited>; let storagePath: string; beforeEach(async () => { tmpRepo = await createTempDir('gitnexus-reconcile-shadow-'); storagePath = getStoragePaths(tmpRepo.dbPath).storagePath; await fs.mkdir(storagePath, { recursive: true }); }); afterEach(async () => { await tmpRepo.cleanup(); }); const metaAt = (indexedAt: string, lastCommit: string): RepoMeta => ({ repoPath: '/some/repo', lastCommit, indexedAt, }); it('a valid gitnexus.json wins over a newer legacy meta.json (both rewritten)', async () => { await fs.writeFile( path.join(storagePath, 'gitnexus.json'), JSON.stringify(metaAt('2026-01-01T00:00:00.000Z', 'stale-commit')), ); await fs.writeFile( path.join(storagePath, 'meta.json'), JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'fresh-commit')), ); await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(true); const primary = JSON.parse( await fs.readFile(path.join(storagePath, 'gitnexus.json'), 'utf-8'), ) as RepoMeta; const legacy = JSON.parse( await fs.readFile(path.join(storagePath, 'meta.json'), 'utf-8'), ) as RepoMeta; expect(primary.lastCommit).toBe('stale-commit'); expect(legacy.lastCommit).toBe('stale-commit'); }); it('bootstraps gitnexus.json from a legacy-only directory (pre-rename repo)', async () => { await fs.writeFile( path.join(storagePath, 'meta.json'), JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'legacy-commit')), ); await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(true); const primary = JSON.parse( await fs.readFile(path.join(storagePath, 'gitnexus.json'), 'utf-8'), ) as RepoMeta; expect(primary.lastCommit).toBe('legacy-commit'); // Legacy file is NOT deleted — it stays as the in-sync mirror. await expect(fs.access(path.join(storagePath, 'meta.json'))).resolves.toBeUndefined(); }); it('is idempotent — a second run with no intervening writes is a no-op', async () => { await fs.writeFile( path.join(storagePath, 'meta.json'), JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'legacy-commit')), ); await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(true); await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(false); }); it('one bad branch dir does not abort reconciliation for sibling branches (F9)', async () => { const branchesDir = path.join(storagePath, 'branches'); const goodA = path.join(branchesDir, 'feat-a'); const goodB = path.join(branchesDir, 'feat-b'); await fs.mkdir(goodA, { recursive: true }); await fs.mkdir(goodB, { recursive: true }); await fs.writeFile( path.join(goodA, 'meta.json'), JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'branch-a')), ); await fs.writeFile( path.join(goodB, 'meta.json'), JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'branch-b')), ); // A dangling symlink sorts between the two healthy dirs ('feat-a' < // 'feat-ax' < 'feat-b'), so pre-fix it would starve feat-b every run. await fs.symlink( path.join(tmpRepo.dbPath, 'does-not-exist'), path.join(branchesDir, 'feat-ax'), ); const cap = _captureLogger(); try { await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(true); } finally { cap.restore(); } // Both healthy branches were bootstrapped despite the bad sibling… await expect(fs.access(path.join(goodA, 'gitnexus.json'))).resolves.toBeUndefined(); await expect(fs.access(path.join(goodB, 'gitnexus.json'))).resolves.toBeUndefined(); // …and the skip is observable, naming the offending branch dir. expect( cap .records() .some( (r) => r.level === 40 && r.branchDir === 'feat-ax' && String(r.msg ?? '').includes('Skipping branch directory'), ), ).toBe(true); }); it('stays silent when branches/ does not exist (not a multi-branch repo)', async () => { await fs.writeFile( path.join(storagePath, 'meta.json'), JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'flat-only')), ); const cap = _captureLogger(); try { await reconcileMetadataFiles(tmpRepo.dbPath); } finally { cap.restore(); } expect(cap.records().filter((r) => r.level === 40)).toEqual([]); }); }); // ─── GitNexus ignore rules (#1233) ───────────────────────────────────── describe('ensureGitNexusIgnored (#1233)', () => { let tmpRepo: Awaited>; beforeEach(async () => { tmpRepo = await createTempDir('gitnexus-internal-gitignore-'); }); afterEach(async () => { vi.restoreAllMocks(); await tmpRepo.cleanup(); }); it('creates .gitnexus/.gitignore containing a catch-all ignore rule', async () => { await ensureGitNexusIgnored(tmpRepo.dbPath); await expect( fs.readFile(path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore'), 'utf-8'), ).resolves.toBe('*\n'); }); it('writes the ignore file to an explicitly selected external storage slot', async () => { const storagePath = path.join(tmpRepo.dbPath, 'central-indexes', 'repo-slot'); await ensureGitNexusIgnored(tmpRepo.dbPath, storagePath); await expect(fs.readFile(path.join(storagePath, '.gitignore'), 'utf-8')).resolves.toBe('*\n'); await expect(fs.access(path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore'))).rejects.toThrow(); }); it('does not create or modify the repository root .gitignore', async () => { const rootGitignorePath = path.join(tmpRepo.dbPath, '.gitignore'); await fs.writeFile(rootGitignorePath, 'node_modules/\n'); await ensureGitNexusIgnored(tmpRepo.dbPath); await expect(fs.readFile(rootGitignorePath, 'utf-8')).resolves.toBe('node_modules/\n'); }); it('adds .gitnexus/ to .git/info/exclude when the repo has a real .git directory', async () => { const excludePath = path.join(tmpRepo.dbPath, '.git', 'info', 'exclude'); await fs.mkdir(path.dirname(excludePath), { recursive: true }); await ensureGitNexusIgnored(tmpRepo.dbPath); await expect(fs.readFile(excludePath, 'utf-8')).resolves.toBe('.gitnexus/\n'); }); it('appends .gitnexus/ to .git/info/exclude once without disturbing existing rules', async () => { const excludePath = path.join(tmpRepo.dbPath, '.git', 'info', 'exclude'); await fs.mkdir(path.dirname(excludePath), { recursive: true }); await fs.writeFile(excludePath, '# local excludes\nnode_modules/\n'); await ensureGitNexusIgnored(tmpRepo.dbPath); await ensureGitNexusIgnored(tmpRepo.dbPath); await expect(fs.readFile(excludePath, 'utf-8')).resolves.toBe( '# local excludes\nnode_modules/\n.gitnexus/\n', ); }); it('does not create .git/info/exclude when .git is not a directory', async () => { await fs.writeFile(path.join(tmpRepo.dbPath, '.git'), 'gitdir: ../real-git-dir\n'); await ensureGitNexusIgnored(tmpRepo.dbPath); await expect(fs.access(path.join(tmpRepo.dbPath, '.git', 'info', 'exclude'))).rejects.toThrow(); }); it('keeps generated .gitnexus files out of git status', async () => { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); await ensureGitNexusIgnored(tmpRepo.dbPath); await fs.writeFile(path.join(tmpRepo.dbPath, '.gitnexus', 'meta.json'), '{}\n'); const status = execSync('git status --short', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }); expect(status).toBe(''); }); // ─ Read-only workspace tolerance (#1549) ──────────────────────────── // The documented Docker workflow mounts the host workspace at /workspace:ro // and runs `gitnexus index /workspace/`. The host has already created // the .gitnexus dir during a prior `analyze`, so the gitignore file already // exists with the correct content — there's no real work to do. The tests // below pin two pieces of behaviour that make that workflow work: // (a) the function short-circuits when the file is already correct // (no write attempt, no mtime bump); // (b) when a write *is* needed but the FS is not writable // (EROFS / EACCES / EPERM), the function logs and continues instead of // throwing — so the caller's `registerRepo` work stays committed. it('does not re-write .gitnexus/.gitignore when it already has the desired content', async () => { await ensureGitNexusIgnored(tmpRepo.dbPath); const gitignorePath = path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore'); const before = await fs.stat(gitignorePath); await new Promise((resolve) => setTimeout(resolve, 25)); await ensureGitNexusIgnored(tmpRepo.dbPath); const after = await fs.stat(gitignorePath); expect(after.mtimeMs).toBe(before.mtimeMs); }); it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( 'does not throw when .gitnexus/.gitignore is already correct and the storage dir is read-only', async () => { await ensureGitNexusIgnored(tmpRepo.dbPath); const storagePath = path.join(tmpRepo.dbPath, '.gitnexus'); await fs.chmod(storagePath, 0o555); try { await expect(ensureGitNexusIgnored(tmpRepo.dbPath)).resolves.not.toThrow(); } finally { await fs.chmod(storagePath, 0o755); } }, ); it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( 'warns and continues when the storage dir is read-only and the file does not yet exist', async () => { const storagePath = path.join(tmpRepo.dbPath, '.gitnexus'); await fs.mkdir(storagePath, { recursive: true }); await fs.chmod(storagePath, 0o555); const cap = _captureLogger(); try { await expect(ensureGitNexusIgnored(tmpRepo.dbPath)).resolves.not.toThrow(); expect( cap .records() .some( (r) => r.level === 40 && (r.code === 'EACCES' || r.code === 'EPERM') && String(r.msg ?? '').includes('.gitnexus/.gitignore') && String(r.path ?? '').includes('.gitnexus'), ), ).toBe(true); } finally { cap.restore(); await fs.chmod(storagePath, 0o755); } }, ); }); // ─── readRegistry ──────────────────────────────────────────────────── describe('readRegistry', () => { it('returns empty array when registry does not exist', async () => { // readRegistry reads from ~/.gitnexus/registry.json // If the file doesn't exist, it should return [] // This test exercises the catch path const result = await readRegistry(); // Result is an array (may or may not be empty depending on user's system) expect(Array.isArray(result)).toBe(true); }); }); // ─── CLI Config (file permissions) ─────────────────────────────────── describe('saveCLIConfig / loadCLIConfig', () => { let tmpHandle: Awaited>; let originalHomedir: typeof os.homedir; beforeEach(async () => { tmpHandle = await createTempDir('gitnexus-config-test-'); originalHomedir = os.homedir; // Mock os.homedir to point to our temp dir // Note: This won't fully work because repo-manager uses its own import of os // We'll test what we can. }); afterEach(async () => { os.homedir = originalHomedir; await tmpHandle.cleanup(); }); it('loadCLIConfig returns empty object when config does not exist', async () => { const config = await loadCLIConfig(); // Returns {} or existing config expect(typeof config).toBe('object'); }); }); // ─── Case-insensitive path comparison (Windows hardening #30) ──────── describe('case-insensitive path comparison', () => { it('registerRepo uses case-insensitive compare on Windows', () => { // The fix is in registerRepo: process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() // We verify the logic inline since we can't easily mock process.platform const compareWindows = (a: string, b: string): boolean => { return a.toLowerCase() === b.toLowerCase(); }; // On Windows, these should match expect(compareWindows('D:\\Projects\\MyApp', 'd:\\projects\\myapp')).toBe(true); expect(compareWindows('C:\\Users\\USER\\project', 'c:\\users\\user\\project')).toBe(true); // Different paths should not match expect(compareWindows('D:\\Projects\\App1', 'D:\\Projects\\App2')).toBe(false); }); it('case-sensitive compare for non-Windows', () => { const compareUnix = (a: string, b: string): boolean => { return a === b; }; // On Unix, case matters expect(compareUnix('/home/user/Project', '/home/user/project')).toBe(false); expect(compareUnix('/home/user/project', '/home/user/project')).toBe(true); }); }); // ─── Windows \\?\ extended-length prefix (#2667) ────────────────────── // // `canonicalizePath` is the single comparison key for the registry, MCP repo // resolution and the server repo routes, and `registryPathEquals` compares its // output as a plain string. A caller-supplied `\\?\` prefix therefore matched // nothing: `path.resolve` preserves the prefix, and the `catch` fallback returns // that resolved path untouched. // // These run only on windows-latest (the file is registered in // scripts/cross-platform-tests.ts): `\\?\` is a Win32 concept, and on POSIX the // same string is just an oddly-named relative file. describe('canonicalizePath vs the \\\\?\\ long-path prefix (#2667)', () => { const isWindows = process.platform === 'win32'; // Realpath branch. libuv's fs__realpath_handle strips the prefix itself, so // this documents the branch that was already safe. it.skipIf(!isWindows)('drops the prefix for a path that exists on disk', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-longpath-')); try { const prefixed = canonicalizePath(`\\\\?\\${dir}`); expect(prefixed.startsWith('\\\\?\\')).toBe(false); expect(registryPathEquals(prefixed, canonicalizePath(dir))).toBe(true); } finally { await fs.rm(dir, { recursive: true, force: true }); } }); // Catch fallback — the branch that actually leaked. `realpathSync.native` // throws for a path that is not on disk (a registry entry rm'd externally, a // `remove`/`clean` alias, an MCP `repo` argument for an unindexed path), so // the resolved string is returned as-is and carried the prefix straight into // the string compare. it.skipIf(!isWindows)('drops the prefix for a path that does not exist', () => { const missing = path.join(os.tmpdir(), 'gn-longpath-absent-2667', 'repo'); const prefixed = canonicalizePath(`\\\\?\\${missing}`); expect(prefixed.startsWith('\\\\?\\')).toBe(false); expect(registryPathEquals(prefixed, canonicalizePath(missing))).toBe(true); }); }); // ─── API key file permissions (hardening #29) ──────────────────────── describe('API key file permissions', () => { it('saveCLIConfig calls chmod 0o600 on non-Windows', async () => { // We verify that the saveCLIConfig code has the chmod call // by reading the source and checking statically. // The actual chmod behavior is platform-dependent. const source = await fs.readFile( path.join(process.cwd(), 'src', 'storage', 'repo-manager.ts'), 'utf-8', ); expect(source).toContain('chmod(configPath, 0o600)'); expect(source).toContain("process.platform !== 'win32'"); }); }); // ─── analyze --name + duplicate-name guard (#829) ──────────── // // Each test isolates the global registry by pointing GITNEXUS_HOME at a // per-test tmpdir. `getGlobalDir()` honors that env var, so registerRepo // writes/reads a sandboxed registry.json without touching the user's // real ~/.gitnexus. describe('registerRepo name override + collision guard (#829)', () => { let tmpHome: Awaited>; let tmpRepoA: Awaited>; let tmpRepoB: Awaited>; let savedGitnexusHome: string | undefined; const meta: RepoMeta = { repoPath: '', lastCommit: 'abc1234', indexedAt: '2026-04-18T12:00:00.000Z', stats: { files: 1, nodes: 1 }, }; beforeEach(async () => { tmpHome = await createTempDir('gitnexus-registry-home-'); tmpRepoA = await createTempDir('gitnexus-repo-a-'); tmpRepoB = await createTempDir('gitnexus-repo-b-'); savedGitnexusHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; }); afterEach(async () => { if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedGitnexusHome; await tmpHome.cleanup(); await tmpRepoA.cleanup(); await tmpRepoB.cleanup(); }); it('registerRepo({ name: "alias" }) stores the alias instead of basename', async () => { await registerRepo(tmpRepoA.dbPath, meta, { name: 'custom-alias' }); const entries = await listRegisteredRepos(); expect(entries).toHaveLength(1); expect(entries[0].name).toBe('custom-alias'); expect(entries[0].name).not.toBe(path.basename(tmpRepoA.dbPath)); }); it('uses an explicitly selected storage slot only when metadata binds to it', async () => { const storagePath = path.join(tmpHome.dbPath, 'central', 'repo-slot'); const boundMeta = { ...meta, repoPath: tmpRepoA.dbPath, storagePath }; await registerRepo(tmpRepoA.dbPath, boundMeta, { storagePath }); expect((await listRegisteredRepos())[0].storagePath).toBe(storagePath); await expect( registerRepo( tmpRepoA.dbPath, { ...boundMeta, storagePath: `${storagePath}-other` }, { storagePath }, ), ).rejects.toThrow('metadata storagePath does not match'); await expect( registerRepo(tmpRepoA.dbPath, { ...meta, repoPath: tmpRepoA.dbPath }, { storagePath }), ).rejects.toThrow('external storage metadata must bind storagePath'); }); it('preserves every concurrent registration', async () => { const repoPaths = Array.from({ length: 12 }, (_, index) => path.join(tmpRepoA.dbPath, `concurrent-${index}`), ); await Promise.all(repoPaths.map((repoPath) => fs.mkdir(repoPath, { recursive: true }))); await Promise.all( repoPaths.map((repoPath, index) => registerRepo(repoPath, meta, { name: `concurrent-${index}` }), ), ); const entries = await listRegisteredRepos(); expect(entries).toHaveLength(repoPaths.length); expect(entries.map((entry) => entry.name).sort()).toEqual( repoPaths.map((_, index) => `concurrent-${index}`).sort(), ); }); it('re-registerRepo on same path without name preserves an existing alias', async () => { await registerRepo(tmpRepoA.dbPath, meta, { name: 'custom-alias' }); // Second call with no opts should keep the alias, not revert to basename. await registerRepo(tmpRepoA.dbPath, meta); const entries = await listRegisteredRepos(); expect(entries).toHaveLength(1); expect(entries[0].name).toBe('custom-alias'); }); it('re-registerRepo with a different name overrides the previous alias', async () => { await registerRepo(tmpRepoA.dbPath, meta, { name: 'old-alias' }); await registerRepo(tmpRepoA.dbPath, meta, { name: 'new-alias' }); const entries = await listRegisteredRepos(); expect(entries).toHaveLength(1); expect(entries[0].name).toBe('new-alias'); }); it('keeps an alias rename committed when an async observer rejects', async () => { await registerRepo(tmpRepoA.dbPath, meta, { name: 'old-alias' }); const onRename = vi.fn(async () => { throw new Error('observer failed'); }); await expect( registerRepo(tmpRepoA.dbPath, meta, { name: 'new-alias', onRename, }), ).resolves.toBe('new-alias'); expect(onRename).toHaveBeenCalledTimes(1); expect(onRename).toHaveBeenCalledWith('old-alias', 'new-alias'); expect(await listRegisteredRepos()).toMatchObject([{ name: 'new-alias' }]); }); it('releases the registry lock before invoking onRename', async () => { await registerRepo(tmpRepoA.dbPath, meta, { name: 'old-alias' }); await registerRepo(tmpRepoA.dbPath, meta, { name: 'new-alias', onRename: async () => { await registerRepo(tmpRepoB.dbPath, meta, { name: 'observer-reentry' }); }, }); expect(await listRegisteredRepos()).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'new-alias' }), expect.objectContaining({ name: 'observer-reentry' }), ]), ); }); it('registerRepo throws RegistryNameCollisionError when another path uses the name', async () => { await registerRepo(tmpRepoA.dbPath, meta, { name: 'shared' }); await expect(registerRepo(tmpRepoB.dbPath, meta, { name: 'shared' })).rejects.toBeInstanceOf( RegistryNameCollisionError, ); // And the colliding entry in the error carries enough info for the // CLI layer to surface an actionable message without string-matching. try { await registerRepo(tmpRepoB.dbPath, meta, { name: 'shared' }); } catch (e) { expect(e).toBeInstanceOf(RegistryNameCollisionError); const err = e as RegistryNameCollisionError; // err.registryName carries the colliding alias (exposed as its own // field so err.name retains the inherited Error.prototype.name // semantics for downstream `err.name === '…Error'` checks). expect(err.registryName).toBe('shared'); expect(err.name).toBe('RegistryNameCollisionError'); expect(path.resolve(err.existingPath)).toBe(path.resolve(tmpRepoA.dbPath)); expect(path.resolve(err.requestedPath)).toBe(path.resolve(tmpRepoB.dbPath)); } // Registry still only has the first entry — the failed call didn't // corrupt state. const entries = await listRegisteredRepos(); expect(entries).toHaveLength(1); expect(entries[0].name).toBe('shared'); }); it('registerRepo({ name, allowDuplicateName: true }) allows the duplicate to coexist', async () => { await registerRepo(tmpRepoA.dbPath, meta, { name: 'shared' }); await registerRepo(tmpRepoB.dbPath, meta, { name: 'shared', allowDuplicateName: true }); const entries = await listRegisteredRepos(); expect(entries).toHaveLength(2); expect(entries.every((e) => e.name === 'shared')).toBe(true); // Both paths are stored distinctly — the collision is surfaced to the // user via resolveRepo / list output, not hidden at the storage layer. const paths = entries.map((e) => path.resolve(e.path)).sort(); expect(paths).toEqual([path.resolve(tmpRepoA.dbPath), path.resolve(tmpRepoB.dbPath)].sort()); }); it('basename collisions without an explicit --name still register silently (backward-compat)', async () => { // Create two sibling dirs whose basenames collide. Neither caller // passes { name }, so the guard must NOT fire — this preserves the // pre-#829 behaviour for users who don't know about --name yet. const parentA = await createTempDir('gitnexus-collide-parent-a-'); const parentB = await createTempDir('gitnexus-collide-parent-b-'); const sharedBasename = 'app'; const pathA = path.join(parentA.dbPath, sharedBasename); const pathB = path.join(parentB.dbPath, sharedBasename); await fs.mkdir(pathA, { recursive: true }); await fs.mkdir(pathB, { recursive: true }); try { await registerRepo(pathA, meta); await registerRepo(pathB, meta); // must NOT throw const entries = await listRegisteredRepos(); expect(entries).toHaveLength(2); expect(entries[0].name).toBe(sharedBasename); expect(entries[1].name).toBe(sharedBasename); } finally { await parentA.cleanup(); await parentB.cleanup(); } }); it('preserves all entries when distinct registrations overlap', async () => { const repos = await Promise.all( Array.from({ length: 6 }, (_, index) => createTempDir(`gitnexus-concurrent-repo-${index}-`)), ); try { await Promise.all( repos.map((repo, index) => registerRepo(repo.dbPath, meta, { name: `concurrent-${index}` }), ), ); const entries = await listRegisteredRepos(); expect(entries).toHaveLength(repos.length); expect(new Set(entries.map((entry) => entry.name))).toEqual( new Set(repos.map((_, index) => `concurrent-${index}`)), ); } finally { await Promise.all(repos.map((repo) => repo.cleanup())); } }); it('keeps an overlapping unregisterRepo and registerRepo from clobbering each other', async () => { await registerRepo(tmpRepoA.dbPath, meta, { name: 'stays' }); await registerRepo(tmpRepoB.dbPath, meta, { name: 'goes' }); const added = await createTempDir('gitnexus-concurrent-added-'); try { await Promise.all([ unregisterRepo(tmpRepoB.dbPath), registerRepo(added.dbPath, meta, { name: 'added' }), ]); const entries = await listRegisteredRepos(); expect(new Set(entries.map((entry) => entry.name))).toEqual(new Set(['stays', 'added'])); } finally { await added.cleanup(); } }); it('registers while an index lock is held on the global directory (#2716)', async () => { // A repo rooted at the user's home directory makes the per-repo analyze // lock target `~/.gitnexus` — the very directory the registry lock would // take if it shared that namespace. `runFullAnalysis` holds the per-repo // lock across its call to `registerRepo` and `acquireIndexLock` is not // reentrant, so a shared namespace self-deadlocks until the wait ceiling // and then degrades. The registry lock lives in its own sub-directory, so // the registration must contend with nothing: no wait announcement, no // degraded-write warning. Asserted on the log rather than elapsed time — // the outcome is what matters, and it stays deterministic on a slow runner. const capture = _captureLogger(); const held = await acquireIndexLock(tmpHome.dbPath); try { await registerRepo(tmpRepoA.dbPath, meta, { name: 'home-rooted' }); } finally { held.release(); capture.restore(); } const logged = capture.records().map((record) => record.msg); expect(logged).not.toContain( 'Waiting for another GitNexus process to finish a registry update…', ); expect(logged).not.toContain( 'Timed out waiting for the global registry lock; proceeding without it. A concurrent registry write may be lost.', ); const entries = await listRegisteredRepos(); expect(entries.map((entry) => entry.name)).toEqual(['home-rooted']); }); }); // ─── registerRepo branch nesting (#2106) ───────────────────────────── // ─── remoteUrl credentials (#2914) ─────────────────────────────────── // // The registry is the surface `list_repos` (MCP), `gitnexus list` and group // sync read from, so a `remoteUrl` carrying `https://user:token@` turns repo // discovery into credential disclosure. Capture-time stripping in // `getRemoteUrl` only covers what THIS version writes — a registry.json (or a // per-repo meta a re-register copies forward) written by an older version // still holds one, so both registry edges sanitize. Fake credential only. describe('registry never emits or persists remoteUrl credentials (#2914)', () => { const FAKE_TOKEN = 'ExAmPle-FAKE-SECRET'; const CREDENTIALED = `https://x-access-token:${FAKE_TOKEN}@github.com/example/project`; const CLEAN = 'https://github.com/example/project'; let tmpHome: Awaited>; let tmpRepo: Awaited>; let savedGitnexusHome: string | undefined; let registryPath: string; const meta: RepoMeta = { repoPath: '', lastCommit: 'abc1234', indexedAt: '2026-08-11T12:00:00.000Z', stats: { files: 1, nodes: 1 }, }; beforeEach(async () => { tmpHome = await createTempDir('gitnexus-2914-home-'); tmpRepo = await createTempDir('gitnexus-2914-repo-'); savedGitnexusHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; registryPath = path.join(tmpHome.dbPath, 'registry.json'); }); afterEach(async () => { if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedGitnexusHome; await tmpHome.cleanup(); await tmpRepo.cleanup(); }); /** A registry.json as an older version would have left it. */ const seedLegacyRegistry = async (entryPath: string): Promise => { const legacy: RegistryEntry[] = [ { name: 'legacy', path: entryPath, storagePath: path.join(entryPath, '.gitnexus'), indexedAt: meta.indexedAt, lastCommit: meta.lastCommit, remoteUrl: CREDENTIALED, }, ]; await fs.writeFile(registryPath, JSON.stringify(legacy, null, 2), 'utf-8'); }; it('sanitizes a legacy on-disk entry before listRegisteredRepos returns it', async () => { await seedLegacyRegistry(tmpRepo.dbPath); const entries = await listRegisteredRepos(); expect(entries).toHaveLength(1); expect(entries[0].remoteUrl).toBe(CLEAN); expect(JSON.stringify(entries)).not.toContain(FAKE_TOKEN); }); it('never writes a credentialed remoteUrl to registry.json', async () => { // meta.remoteUrl bypasses getRemoteUrl entirely — this is the legacy // per-repo gitnexus.json being copied forward into a fresh registry. await registerRepo(tmpRepo.dbPath, { ...meta, remoteUrl: CREDENTIALED }, { name: 'repro' }); const raw = await fs.readFile(registryPath, 'utf-8'); expect(raw).not.toContain(FAKE_TOKEN); expect((JSON.parse(raw) as RegistryEntry[])[0].remoteUrl).toBe(CLEAN); }); it('scrubs an untouched legacy entry when some other repo is registered', async () => { const other = await createTempDir('gitnexus-2914-other-'); try { await seedLegacyRegistry(other.dbPath); await registerRepo(tmpRepo.dbPath, meta, { name: 'fresh' }); const raw = await fs.readFile(registryPath, 'utf-8'); expect(raw).not.toContain(FAKE_TOKEN); // The legacy entry survives — it is scrubbed, not dropped. expect(JSON.parse(raw)).toHaveLength(2); } finally { await other.cleanup(); } }); it('still matches sibling clones after sanitization (#2054 fingerprint)', async () => { await registerRepo(tmpRepo.dbPath, { ...meta, remoteUrl: CREDENTIALED }, { name: 'with-cred' }); const other = await createTempDir('gitnexus-2914-sibling-'); try { await registerRepo(other.dbPath, { ...meta, remoteUrl: CLEAN }, { name: 'clean' }); const entries = await listRegisteredRepos(); const remotes = entries.map((e) => e.remoteUrl); expect(remotes).toEqual([CLEAN, CLEAN]); } finally { await other.cleanup(); } }); }); describe('registerRepo branch nesting (#2106)', () => { let tmpHome: Awaited>; let tmpRepo: Awaited>; let savedGitnexusHome: string | undefined; const metaFor = (branch: string, lastCommit: string): RepoMeta => ({ repoPath: '', lastCommit, indexedAt: '2026-06-10T12:00:00.000Z', branch, stats: { files: 1, nodes: 1 }, }); beforeEach(async () => { tmpHome = await createTempDir('gitnexus-registry-branch-home-'); tmpRepo = await createTempDir('gitnexus-registry-branch-repo-'); savedGitnexusHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; }); afterEach(async () => { if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedGitnexusHome; await tmpHome.cleanup(); await tmpRepo.cleanup(); }); it('primary run records branch at top level and no branches[]', async () => { await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); const [entry] = await listRegisteredRepos(); expect(entry.branch).toBe('main'); expect(entry.lastCommit).toBe('aaa1111'); expect(entry.branches).toBeUndefined(); }); it('branch run nests under branches[] without clobbering the primary', async () => { await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); const entries = await listRegisteredRepos(); expect(entries).toHaveLength(1); // one entry per path preserved const [entry] = entries; // Primary top-level fields untouched. expect(entry.branch).toBe('main'); expect(entry.lastCommit).toBe('aaa1111'); // Branch summary nested. expect(entry.branches).toHaveLength(1); expect(entry.branches?.[0]).toMatchObject({ branch: 'feature/x', lastCommit: 'bbb2222' }); }); it('re-registering the same branch updates in place (no duplicate)', async () => { await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'ccc3333'), { branch: 'feature/x' }); const [entry] = await listRegisteredRepos(); expect(entry.branches).toHaveLength(1); expect(entry.branches?.[0].lastCommit).toBe('ccc3333'); }); it('primary re-analyze preserves existing branch summaries', async () => { await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); // Re-analyze the primary at a newer commit. await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa9999')); const [entry] = await listRegisteredRepos(); expect(entry.lastCommit).toBe('aaa9999'); expect(entry.branches).toHaveLength(1); expect(entry.branches?.[0].branch).toBe('feature/x'); }); // ─── removeBranchIndex (#2106 R7) ────────────────────────────────── it('removeBranchIndex drops a recorded branch summary, leaves the primary', async () => { await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); const removed = await removeBranchIndex(tmpRepo.dbPath, 'feature/x'); expect(removed).toBe(true); const [entry] = await listRegisteredRepos(); expect(entry.branch).toBe('main'); // primary intact expect(entry.lastCommit).toBe('aaa1111'); expect(entry.branches).toBeUndefined(); // empty branches[] dropped }); it('removeBranchIndex returns false for an unknown branch (no crash)', async () => { await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); expect(await removeBranchIndex(tmpRepo.dbPath, 'nope')).toBe(false); const [entry] = await listRegisteredRepos(); expect(entry.branches).toHaveLength(1); // unchanged }); it('removeBranchIndex returns false when the repo has no branch indexes', async () => { await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); expect(await removeBranchIndex(tmpRepo.dbPath, 'feature/x')).toBe(false); }); it('removeBranchIndex keeps other branch summaries when removing one', async () => { await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); await registerRepo(tmpRepo.dbPath, metaFor('feature/y', 'ccc3333'), { branch: 'feature/y' }); await removeBranchIndex(tmpRepo.dbPath, 'feature/x'); const [entry] = await listRegisteredRepos(); expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/y']); }); it('overlapping removeBranchIndex calls drop both summaries (#2716)', async () => { await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); await registerRepo(tmpRepo.dbPath, metaFor('feature/y', 'ccc3333'), { branch: 'feature/y' }); // Unserialized, both writers read the same two-branch snapshot and the // last rename wins — one summary survives as a lost update. const removed = await Promise.all([ removeBranchIndex(tmpRepo.dbPath, 'feature/x'), removeBranchIndex(tmpRepo.dbPath, 'feature/y'), ]); expect(removed).toEqual([true, true]); const [entry] = await listRegisteredRepos(); expect(entry.branch).toBe('main'); // primary intact expect(entry.branches).toBeUndefined(); }); // ─── adoptFlatBranchLabel (#2354) ─────────────────────────────────── it('adoptFlatBranchLabel relabels the entry and removes a shadowed sub-index', async () => { await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); // Materialize the pinned sub-index on disk so the shadow cleanup has a // real directory to remove. const { metaPath } = getStoragePaths(tmpRepo.dbPath, 'feature/x'); await saveMeta(path.dirname(metaPath), metaFor('feature/x', 'bbb2222')); await saveMeta(getStoragePaths(tmpRepo.dbPath).storagePath, { ...metaFor('main', 'aaa1111'), repoPath: tmpRepo.dbPath, }); await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x'); const [entry] = await listRegisteredRepos(); expect(entry.branch).toBe('feature/x'); expect(entry.branches).toBeUndefined(); // shadowed summary dropped await expect(fs.access(path.dirname(metaPath))).rejects.toThrow(); // dir deleted }); it('adoptFlatBranchLabel keeps other pinned branch summaries', async () => { await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); await registerRepo(tmpRepo.dbPath, metaFor('feature/y', 'ccc3333'), { branch: 'feature/y' }); await saveMeta(getStoragePaths(tmpRepo.dbPath).storagePath, { ...metaFor('main', 'aaa1111'), repoPath: tmpRepo.dbPath, }); await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x'); const [entry] = await listRegisteredRepos(); expect(entry.branch).toBe('feature/x'); expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/y']); }); it('adoptFlatBranchLabel never self-heals an unregistered repo', async () => { // No registerRepo call — the registry has no entry for this path (#2264/#1169). // The no-op must cover the disk too: a materialized pinned sub-index // survives, because the shadow rm only runs for registered repos // (#2364 review F2 — the rm used to fire before the registry check). const { metaPath } = getStoragePaths(tmpRepo.dbPath, 'feature/x'); await saveMeta(path.dirname(metaPath), metaFor('feature/x', 'bbb2222')); await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x'); expect(await listRegisteredRepos()).toHaveLength(0); await expect(fs.access(path.dirname(metaPath))).resolves.toBeUndefined(); // dir survives }); it('does not delete an explicitly selected external branch slot after ownership changes', async () => { const storagePath = path.join(tmpHome.dbPath, 'central-indexes', 'repo-slot'); const ownedMeta = { ...metaFor('main', 'aaa1111'), repoPath: tmpRepo.dbPath, storagePath, }; await saveMeta(storagePath, ownedMeta); await registerRepo(tmpRepo.dbPath, ownedMeta, { storagePath }); const shadowDir = path.dirname( getStoragePaths(tmpRepo.dbPath, 'feature/x', storagePath).metaPath, ); await fs.mkdir(shadowDir, { recursive: true }); await saveMeta(storagePath, { ...ownedMeta, repoPath: tmpHome.dbPath }); await expect(adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x', storagePath)).rejects.toThrow( 'storage is not owned', ); await expect(fs.access(shadowDir)).resolves.toBeUndefined(); }); // ─── re-read-before-write merge (#2106 R9) ────────────────────────── it('a branch run preserves the freshest top-level fields (alias survives)', async () => { // A primary run set an alias; a later branch run must keep it (re-derives // against the fresh snapshot, not a stale entry-time view). await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111'), { name: 'my-alias' }); await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); const [entry] = await listRegisteredRepos(); expect(entry.name).toBe('my-alias'); // top-level alias survived the branch run expect(entry.branch).toBe('main'); expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/x']); }); }); // ─── parseRepoNameFromUrl + getInferredRepoName (#979) ─────────────── describe('parseRepoNameFromUrl', () => { it('parses HTTPS URLs and strips .git', () => { expect(parseRepoNameFromUrl('https://github.com/owner/lume_spark.git')).toBe('lume_spark'); expect(parseRepoNameFromUrl('https://github.com/owner/lume_spark')).toBe('lume_spark'); }); it('parses SSH URLs (git@host:owner/repo.git)', () => { expect(parseRepoNameFromUrl('git@github.com:owner/lume_spark.git')).toBe('lume_spark'); expect(parseRepoNameFromUrl('git@gitlab.com:group/sub/lume_spark.git')).toBe('lume_spark'); }); it('parses ssh:// and git:// URLs', () => { expect(parseRepoNameFromUrl('ssh://git@host.example/owner/lume_spark.git')).toBe('lume_spark'); expect(parseRepoNameFromUrl('git://host.example/owner/lume_spark.git')).toBe('lume_spark'); }); it('parses local file:// URLs', () => { expect(parseRepoNameFromUrl('file:///srv/git/lume_spark.git')).toBe('lume_spark'); }); it('handles trailing slashes and mixed-case .git', () => { expect(parseRepoNameFromUrl('https://github.com/owner/lume_spark.GIT/')).toBe('lume_spark'); expect(parseRepoNameFromUrl('https://github.com/owner/lume_spark/')).toBe('lume_spark'); }); it('returns null for empty / null / undefined / unparseable input', () => { expect(parseRepoNameFromUrl('')).toBeNull(); expect(parseRepoNameFromUrl(' ')).toBeNull(); expect(parseRepoNameFromUrl(null)).toBeNull(); expect(parseRepoNameFromUrl(undefined)).toBeNull(); }); }); describe('getInferredRepoName + registerRepo (#979 — git remote inference)', () => { let tmpHome: Awaited>; let savedGitnexusHome: string | undefined; const meta: RepoMeta = { repoPath: '', lastCommit: 'abc1234', indexedAt: '2026-04-19T00:00:00.000Z', stats: { files: 1, nodes: 1 }, }; /** Initialise a real git repo at `dir` with the given remote URL. */ const initGitRepo = (dir: string, remoteUrl: string | null) => { execSync('git init -q', { cwd: dir }); execSync('git config user.email "test@example.com"', { cwd: dir }); execSync('git config user.name "Test"', { cwd: dir }); if (remoteUrl) { execSync(`git remote add origin ${remoteUrl}`, { cwd: dir }); } }; beforeEach(async () => { tmpHome = await createTempDir('gitnexus-registry-home-979-'); savedGitnexusHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; }); afterEach(async () => { if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedGitnexusHome; await tmpHome.cleanup(); }); it('getInferredRepoName returns null when there is no .git directory', async () => { const tmp = await createTempDir('gitnexus-no-git-'); try { expect(getInferredRepoName(tmp.dbPath)).toBeNull(); } finally { await tmp.cleanup(); } }); it('getInferredRepoName returns null when origin is unset', async () => { const tmp = await createTempDir('gitnexus-no-origin-'); try { initGitRepo(tmp.dbPath, null); expect(getInferredRepoName(tmp.dbPath)).toBeNull(); } finally { await tmp.cleanup(); } }); it('getInferredRepoName returns the remote repo name when origin is set', async () => { const tmp = await createTempDir('gitnexus-with-origin-'); try { initGitRepo(tmp.dbPath, 'https://github.com/owner/lume_spark.git'); expect(getInferredRepoName(tmp.dbPath)).toBe('lume_spark'); } finally { await tmp.cleanup(); } }); it('registerRepo derives name from git remote when basename is generic (Gas-Town repro)', async () => { // Reproduce /refinery/rig/.git layout: leaf basename is "rig", // but origin URL says "lume_spark". The new precedence MUST pick up // the remote-derived name instead of the basename. const root = await createTempDir('gitnexus-gastown-'); try { const rigPath = path.join(root.dbPath, 'lume_spark', 'refinery', 'rig'); await fs.mkdir(rigPath, { recursive: true }); initGitRepo(rigPath, 'git@github.com:gastown/lume_spark.git'); const name = await registerRepo(rigPath, meta); expect(name).toBe('lume_spark'); expect(name).not.toBe('rig'); const entries = await listRegisteredRepos(); expect(entries).toHaveLength(1); expect(entries[0].name).toBe('lume_spark'); } finally { await root.cleanup(); } }); it('two analyze calls of differently-remoted "rig" leaves no longer collide', async () => { // Without the remote inference both would register as "rig"; with // inference they pick up their distinct remotes — the original issue. const root = await createTempDir('gitnexus-gastown-2-'); try { const rigA = path.join(root.dbPath, 'lume_spark', 'refinery', 'rig'); const rigB = path.join(root.dbPath, 'gemba', 'refinery', 'rig'); await fs.mkdir(rigA, { recursive: true }); await fs.mkdir(rigB, { recursive: true }); initGitRepo(rigA, 'git@github.com:gastown/lume_spark.git'); initGitRepo(rigB, 'git@github.com:gastown/gemba.git'); const nameA = await registerRepo(rigA, meta); const nameB = await registerRepo(rigB, meta); expect(nameA).toBe('lume_spark'); expect(nameB).toBe('gemba'); const entries = await listRegisteredRepos(); expect(entries.map((e) => e.name).sort()).toEqual(['gemba', 'lume_spark']); } finally { await root.cleanup(); } }); it('explicit --name still wins over remote inference', async () => { const tmp = await createTempDir('gitnexus-name-wins-'); try { initGitRepo(tmp.dbPath, 'https://github.com/owner/from-remote.git'); const name = await registerRepo(tmp.dbPath, meta, { name: 'user-alias' }); expect(name).toBe('user-alias'); } finally { await tmp.cleanup(); } }); it('preserved alias still wins over remote inference on re-analyze', async () => { const tmp = await createTempDir('gitnexus-preserve-alias-'); try { initGitRepo(tmp.dbPath, 'https://github.com/owner/from-remote.git'); // First analyze sets the alias… await registerRepo(tmp.dbPath, meta, { name: 'sticky-alias' }); // …second analyze with no opts must keep it (not silently switch // to the remote-derived name). const name = await registerRepo(tmp.dbPath, meta); expect(name).toBe('sticky-alias'); } finally { await tmp.cleanup(); } }); it('falls back to basename when no .git / no remote is available', async () => { const tmp = await createTempDir('gitnexus-fallback-basename-'); try { const name = await registerRepo(tmp.dbPath, meta); expect(name).toBe(path.basename(tmp.dbPath)); } finally { await tmp.cleanup(); } }); }); // ─── resolveRegistryEntry (#664 — gitnexus remove ) ────────── // // The resolver is a pure function over a `RegistryEntry[]` snapshot, so // these tests build synthetic entries inline and do NOT touch // ~/.gitnexus. No GITNEXUS_HOME sandboxing needed. This also means the // tests are platform-portable on Windows where realpath semantics on // tmpdirs can diverge between runs (see the #955 CI pivot). describe('resolveRegistryEntry (#664)', () => { // A well-known synthetic registry with two same-name entries (which // can only exist in reality after `--allow-duplicate-name` — #829) and // one unique-name entry. Path prefixes differ across platforms so the // tests stay meaningful regardless of `process.platform`. const prefix = process.platform === 'win32' ? 'D:\\' : '/tmp/'; const pathA = `${prefix}projects${path.sep}gnx-a${path.sep}app`; const pathB = `${prefix}projects${path.sep}gnx-b${path.sep}app`; const pathW = `${prefix}work${path.sep}website`; const entries: RegistryEntry[] = [ { name: 'app', path: pathA, storagePath: `${pathA}${path.sep}.gitnexus`, indexedAt: '2026-04-18T00:00:00.000Z', lastCommit: 'aaaaaaa', }, { name: 'app', path: pathB, storagePath: `${pathB}${path.sep}.gitnexus`, indexedAt: '2026-04-18T00:00:00.000Z', lastCommit: 'bbbbbbb', }, { name: 'website', path: pathW, storagePath: `${pathW}${path.sep}.gitnexus`, indexedAt: '2026-04-18T00:00:00.000Z', lastCommit: 'ccccccc', }, ]; it('resolves by absolute path to the exact entry (path tier beats name tier)', () => { const hit = resolveRegistryEntry(entries, pathA); expect(hit).toBe(entries[0]); expect(hit.path).toBe(pathA); const hit2 = resolveRegistryEntry(entries, pathB); expect(hit2).toBe(entries[1]); expect(hit2.path).toBe(pathB); }); it('resolves by unique name to the only matching entry', () => { const hit = resolveRegistryEntry(entries, 'website'); expect(hit).toBe(entries[2]); expect(hit.name).toBe('website'); }); it('name match is case-insensitive', () => { expect(resolveRegistryEntry(entries, 'WEBSITE')).toBe(entries[2]); expect(resolveRegistryEntry(entries, 'Website')).toBe(entries[2]); }); it('findRegistryEntryByName is name-only: a filesystem path is a miss, not a path-tier hit', () => { expect(findRegistryEntryByName(entries, pathA)).toBeUndefined(); expect(findRegistryEntryByName(entries, 'website')).toBe(entries[2]); expect(findRegistryEntryByName(entries, 'WEBSITE')).toBe(entries[2]); expect(() => findRegistryEntryByName(entries, 'app')).toThrow(RegistryAmbiguousTargetError); }); it('path match is case-insensitive on Windows only', () => { if (process.platform !== 'win32') { // On POSIX, a differently-cased path must NOT match. Verify by // lower-casing a mixed-case copy of pathW and expecting a miss. const upper = pathW.toUpperCase(); expect(() => resolveRegistryEntry(entries, upper)).toThrow(RegistryNotFoundError); return; } const upper = pathA.toUpperCase(); const hit = resolveRegistryEntry(entries, upper); expect(hit).toBe(entries[0]); }); it('throws RegistryAmbiguousTargetError when name matches multiple entries', () => { // Two 'app' entries exist only because of --allow-duplicate-name // (#829). The resolver MUST refuse to guess. expect(() => resolveRegistryEntry(entries, 'app')).toThrow(RegistryAmbiguousTargetError); try { resolveRegistryEntry(entries, 'app'); } catch (e) { expect(e).toBeInstanceOf(RegistryAmbiguousTargetError); const err = e as RegistryAmbiguousTargetError; expect(err.kind).toBe('RegistryAmbiguousTargetError'); expect(err.target).toBe('app'); expect(err.matches).toHaveLength(2); // Error message must include both paths so the CLI can surface // them without string-matching on `.message`. expect(err.message).toContain(pathA); expect(err.message).toContain(pathB); } }); it('throws RegistryNotFoundError when no entry matches', () => { expect(() => resolveRegistryEntry(entries, 'nonexistent')).toThrow(RegistryNotFoundError); try { resolveRegistryEntry(entries, 'nonexistent'); } catch (e) { expect(e).toBeInstanceOf(RegistryNotFoundError); const err = e as RegistryNotFoundError; expect(err.kind).toBe('RegistryNotFoundError'); expect(err.target).toBe('nonexistent'); // availableNames is disambiguated: 'app' appears twice, so both // `app (path)` variants are included; 'website' is unique so it // stays plain — matches the resolveRepo disambiguation shape. expect(err.availableNames).toContain('website'); expect(err.availableNames.some((n) => n.startsWith('app ('))).toBe(true); // Error message surfaces the hint. expect(err.message).toContain('website'); } }); it('throws RegistryNotFoundError with "no repositories registered" hint when registry is empty', () => { try { resolveRegistryEntry([], 'anything'); } catch (e) { expect(e).toBeInstanceOf(RegistryNotFoundError); const err = e as RegistryNotFoundError; expect(err.availableNames).toEqual([]); expect(err.message).toContain('No repositories are currently registered'); } }); it('path match wins over name match (never ambiguous)', () => { // Construct a pathological fixture where a registry entry's NAME // happens to equal another entry's PATH. The path tier must win // without triggering ambiguity. const weird: RegistryEntry[] = [ { ...entries[2] }, // 'website' at pathW { name: pathW, // degenerate: name equals another entry's path path: `${prefix}elsewhere${path.sep}odd`, storagePath: `${prefix}elsewhere${path.sep}odd${path.sep}.gitnexus`, indexedAt: '2026-04-18T00:00:00.000Z', lastCommit: 'ddddddd', }, ]; const hit = resolveRegistryEntry(weird, pathW); // Must match the entry whose PATH is pathW, not the one whose NAME // is pathW — because Tier 1 runs before Tier 2 and finds the path // match first. expect(hit.path).toBe(pathW); expect(hit.name).toBe('website'); }); }); // ─── canonicalizePath (#1003 review — @evander-wang / @magyargergo) ── // // Shields `registerRepo`, `unregisterRepo`, and `resolveRegistryEntry` // against cross-platform path-form divergence: macOS symlink expansion // (/var → /private/var) and Windows 8.3 short-name expansion // (RUNNERA~1 → runneradmin). The helper also underpins backwards // compatibility with registries written by versions that only ran // `path.resolve` — by canonicalising the stored entry at compare time, // both pre- and post-fix entries converge to the same key. // // These tests avoid snapshotting a specific realpath value (that would // be platform-fragile); instead they assert: // - canonicalizePath is idempotent (f(f(x)) == f(x)) // - canonicalizePath falls back cleanly when the path doesn't exist // - resolveRegistryEntry matches a stored entry even when the target // and the stored value disagree on one-step normalisation (simulated // via a fixture that stores the de-canonicalised form of a real // existing path). describe('canonicalizePath (#1003)', () => { it('is idempotent — canonicalizePath(canonicalizePath(x)) === canonicalizePath(x)', async () => { // Use the vitest project-root as a known-existing path. `os.tmpdir()` // would work too but process.cwd() is guaranteed to exist for the // test runner. const p = process.cwd(); const once = canonicalizePath(p); const twice = canonicalizePath(once); expect(twice).toBe(once); }); it('falls back to path.resolve when the target does not exist', () => { // Construct a definitely-nonexistent path under tmpdir. Using // random-ish segments so we don't collide with anything real. const ghost = path.join(os.tmpdir(), 'gnx-never-exists-____', 'still-not-there'); const got = canonicalizePath(ghost); // Must not throw, must not resolve to something weird — should be // identical to `path.resolve(ghost)` since realpathSync.native will // have thrown and we swallowed it. expect(got).toBe(path.resolve(ghost)); }); it('returns an absolute path for relative input even when the path is missing', () => { // Relative path that does not exist. Must still be absolute // (fallback path: path.resolve normalises even non-existent inputs). const rel = './does-not-exist-zzz-' + Date.now(); const got = canonicalizePath(rel); expect(path.isAbsolute(got)).toBe(true); }); }); describe('cloneDirBelongsToEntry', () => { it('returns true when the clone dir and entry.path canonicalize to the same dir', () => { // Non-canonical spelling of a REAL path (same trick as the // resolveRegistryEntry backward-compat test): raw concat keeps the // strings unequal until canonicalizePath runs. const realDir = process.cwd(); const nonCanonical = realDir + path.sep + '.'; expect(nonCanonical).not.toBe(realDir); expect(cloneDirBelongsToEntry(nonCanonical, realDir)).toBe(true); }); it('returns false when the entry.path lives elsewhere than the clone dir', () => { // The delete-handler scenario: entry B is a local repo whose name // collides with clone A's — its path must not claim A's clone dir. const cloneDir = path.join(os.tmpdir(), 'gitnexus-clones', 'reels'); const entryPath = path.join(os.tmpdir(), 'local', 'reels'); expect(cloneDirBelongsToEntry(cloneDir, entryPath)).toBe(false); }); it('compares nonexistent paths without throwing (realpath falls back to path.resolve)', () => { // Neither side exists on disk — canonicalizePath must fall back to // path.resolve on both, so equal strings still compare equal. const ghost = path.join(os.tmpdir(), 'gnx-never-exists-____', 'clone-dir'); expect(cloneDirBelongsToEntry(ghost, ghost)).toBe(true); }); }); describe('resolveRegistryEntry backward-compat with non-canonical stored paths (#1003)', () => { it('matches a stored entry even when the target was passed in canonical form', async () => { // Simulate the bug-producing scenario without depending on a real // symlink/8.3 discrepancy (those are platform-specific and flaky to // set up in CI). We take a REAL path that exists // (canonicalizePath-stable), store a known-non-canonical copy of it // in a fake RegistryEntry, then resolve with the canonical form and // assert the match. // // Construct a non-canonical string that resolves to the same real // path. `path.join` auto-normalises `.` and trailing separators, so // we build the string by raw concat to keep it string-unequal to // `realDir` until `canonicalizePath` runs. const realDir = process.cwd(); const nonCanonical = realDir + path.sep + '.'; // e.g. /work/gitnexus/. // Sanity: these are string-unequal before canonicalisation. expect(nonCanonical).not.toBe(realDir); const entries: RegistryEntry[] = [ { name: 'stored-under-noncanonical-form', path: nonCanonical, storagePath: path.join(nonCanonical, '.gitnexus'), indexedAt: '2026-04-20T00:00:00.000Z', lastCommit: 'deadbee', }, ]; // Pass the canonical form as the target — resolver must still match. const hit = resolveRegistryEntry(entries, realDir); expect(hit).toBe(entries[0]); }); }); // ─── assertSafeStoragePath (#1003 review — @magyargergo) ───────────── // // Guard rail against destroying more than the `.gitnexus/` subfolder. // `~/.gitnexus/registry.json` is user-writable plain text, so a // corrupted or hand-edited entry could put storagePath anywhere. // Repository-local paths remain pure string checks. External slots require // metadata ownership proof before they may be recursively deleted. describe('assertSafeStoragePath (#1003)', () => { const prefix = process.platform === 'win32' ? 'D:\\' : '/tmp/'; const repoPath = `${prefix}projects${path.sep}my-repo`; const base: Omit = { name: 'my-repo', path: repoPath, indexedAt: '2026-04-21T00:00:00.000Z', lastCommit: 'deadbee', }; it('accepts the canonical /.gitnexus storage path', async () => { const entry: RegistryEntry = { ...base, storagePath: path.join(repoPath, '.gitnexus'), }; await expect(assertSafeStoragePath(entry)).resolves.toBeUndefined(); }); it('rejects when storagePath equals the repo path itself (would delete the code)', async () => { const entry: RegistryEntry = { ...base, storagePath: repoPath, // catastrophic: rm the working tree }; await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); }); it('rejects when storagePath is a parent of the repo path', async () => { const entry: RegistryEntry = { ...base, storagePath: path.dirname(repoPath), // also catastrophic }; await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); }); it('rejects when storagePath is empty (path.resolve falls back to cwd)', async () => { const entry: RegistryEntry = { ...base, storagePath: '', // path.resolve('') === process.cwd() — would rm cwd }; await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); }); it.each([null, 42])('rejects malformed storagePath %p with the safety error', async (value) => { const entry = { ...base, storagePath: value, } as unknown as RegistryEntry; try { await assertSafeStoragePath(entry); expect.unreachable('expected malformed storagePath to be rejected'); } catch (error) { expect(error).toBeInstanceOf(UnsafeStoragePathError); expect((error as UnsafeStoragePathError).actualStoragePath).toBe(String(value)); } }); it('rejects when storagePath points somewhere totally unrelated', async () => { const entry: RegistryEntry = { ...base, storagePath: `${prefix}some${path.sep}other${path.sep}place`, }; await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); }); it('rejects when storagePath is a sibling .gitnexus (right basename, wrong parent)', async () => { const entry: RegistryEntry = { ...base, storagePath: path.join(`${prefix}different${path.sep}repo`, '.gitnexus'), }; await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); }); it('UnsafeStoragePathError carries the original entry + expected + actual paths', async () => { const entry: RegistryEntry = { ...base, storagePath: `${prefix}evil${path.sep}path`, }; try { await assertSafeStoragePath(entry); } catch (e) { expect(e).toBeInstanceOf(UnsafeStoragePathError); const err = e as UnsafeStoragePathError; expect(err.kind).toBe('UnsafeStoragePathError'); expect(err.entry).toBe(entry); // Expected path is the canonical `/.gitnexus`. expect(err.expectedStoragePath).toBe(path.join(path.resolve(repoPath), '.gitnexus')); // Actual path is the corrupted value (resolved). expect(err.actualStoragePath).toBe(path.resolve(entry.storagePath)); // Message must suggest the recovery action. expect(err.message).toContain('registry.json'); } }); it('Windows: storagePath match is case-insensitive to match register/unregister semantics', async () => { if (process.platform !== 'win32') return; const entry: RegistryEntry = { ...base, storagePath: path.join(repoPath.toUpperCase(), '.GITNEXUS'), }; // Should accept because Windows paths are case-insensitive. await expect(assertSafeStoragePath(entry)).resolves.toBeUndefined(); }); it('accepts an external slot only when its metadata binds it to the registry entry', async () => { const repo = await createTempDir('gitnexus-external-repo-'); const storage = await createTempDir('gitnexus-external-storage-'); const entry: RegistryEntry = { ...base, path: repo.dbPath, storagePath: storage.dbPath, }; try { await saveMeta(storage.dbPath, { repoPath: repo.dbPath, storagePath: storage.dbPath, lastCommit: 'deadbee', indexedAt: new Date(0).toISOString(), }); await expect(assertSafeStoragePath(entry)).resolves.toBeUndefined(); } finally { await Promise.all([repo.cleanup(), storage.cleanup()]); } }); it('rejects an external slot whose metadata belongs to another checkout', async () => { const repo = await createTempDir('gitnexus-external-repo-'); const storage = await createTempDir('gitnexus-external-storage-'); const entry: RegistryEntry = { ...base, path: repo.dbPath, storagePath: storage.dbPath, }; try { await saveMeta(storage.dbPath, { repoPath: path.join(repo.dbPath, 'other'), storagePath: storage.dbPath, lastCommit: 'deadbee', indexedAt: new Date(0).toISOString(), }); await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); } finally { await Promise.all([repo.cleanup(), storage.cleanup()]); } }); }); // ─── Worktree-aware registry-name fallback (#1259) ───────────────────── // // The first @claude review on PR #1296 caught a critical gap: my initial // fix only patched the early-return path in `runFullAnalysis`, leaving // the full-analysis path (which calls `registerRepo` directly) still // using the worktree-slug basename when no `--name` and no remote are // configured. This block proves `registerRepo`'s OWN basename fallback // now uses the canonical repo root via `getCanonicalRepoRoot` — the // regression-guard for the wiring at the registry layer, complementing // the helper-level coverage in `git-utils.test.ts`. describe('registerRepo worktree-aware basename fallback (#1259)', () => { let tmpHome: Awaited>; let tmpRepo: Awaited>; let savedGitnexusHome: string | undefined; const meta: RepoMeta = { repoPath: '', lastCommit: 'abc1234', indexedAt: '2026-05-03T00:00:00.000Z', stats: { files: 1, nodes: 1 }, }; beforeEach(async () => { tmpHome = await createTempDir('gitnexus-registry-home-'); tmpRepo = await createTempDir('gitnexus-canonical-repo-'); savedGitnexusHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; }); afterEach(async () => { if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedGitnexusHome; await tmpHome.cleanup(); await tmpRepo.cleanup(); }); it('registerRepo from a linked worktree uses canonical repo basename, not worktree slug', async () => { // Set up a real git repo with at least one commit (worktree add requires // a non-empty branch). No remote is configured — that's the trigger for // the basename fallback this test guards. execSync('git init -q', { cwd: tmpRepo.dbPath }); execSync('git config user.email "test@example.com"', { cwd: tmpRepo.dbPath }); execSync('git config user.name "Test"', { cwd: tmpRepo.dbPath }); execSync('git commit --allow-empty -q -m "initial"', { cwd: tmpRepo.dbPath }); const worktreeDir = path.join(tmpRepo.dbPath, 'wt-feature'); execSync(`git worktree add -q -b feature "${worktreeDir}"`, { cwd: tmpRepo.dbPath }); try { // Call registerRepo with the WORKTREE path and NO --name. Pre-fix this // would register under the worktree's basename ("wt-feature"). The // canonical-root fallback in registerRepo now resolves it to the // canonical repo's basename (whatever `tmpRepo`'s temp-dir basename // happens to be). await registerRepo(worktreeDir, meta); const entries = await listRegisteredRepos(); expect(entries).toHaveLength(1); // The registered name MUST NOT be the worktree slug. expect(entries[0].name).not.toBe('wt-feature'); // It MUST match the canonical repo dir's basename. We compare via // basename (not full-path equality) for the same Windows 8.3 // short-name reason as the `getCanonicalRepoRoot` helper tests: // git and `fs.realpathSync` may resolve to different long/short // forms of the same path on Windows runners, but both have the // same `basename`. expect(entries[0].name).toBe(path.basename(tmpRepo.dbPath)); } finally { // Best-effort worktree teardown before the temp-dir cleanup runs. try { execSync(`git worktree remove -f "${worktreeDir}"`, { cwd: tmpRepo.dbPath }); } catch { // Falls through to recursive rm in afterEach. } } }); // Pinned by the second @claude review on PR #1296: the FIRST review-fix // commit (`7ceb839b`) introduced a regression in `hasCustomAlias`. Once // a worktree is registered with the canonical basename // (`{name: 'repo', path: '/repo/wt-feature'}`), `hasCustomAlias` saw // `'repo' !== path.basename('/repo/wt-feature') = 'wt-feature'` and // wrongly classified the canonical-root name as a sticky user alias. // On re-analyze the duplicate-name guard then fired against the // canonical checkout's entry → `RegistryNameCollisionError` blocking // the primary "per-task worktree, repeated re-analyze" workflow this // PR is supposed to FIX. This test exercises the full sequence: // canonical → worktree → re-worktree, with both paths registered. it('canonical → worktree → re-worktree re-register does not throw collision (#1259 hasCustomAlias regression)', async () => { execSync('git init -q', { cwd: tmpRepo.dbPath }); execSync('git config user.email "test@example.com"', { cwd: tmpRepo.dbPath }); execSync('git config user.name "Test"', { cwd: tmpRepo.dbPath }); execSync('git commit --allow-empty -q -m "initial"', { cwd: tmpRepo.dbPath }); const worktreeDir = path.join(tmpRepo.dbPath, 'wt-feature'); execSync(`git worktree add -q -b feature "${worktreeDir}"`, { cwd: tmpRepo.dbPath }); try { // 1. Register the canonical checkout — gets the canonical basename. await registerRepo(tmpRepo.dbPath, meta); // 2. Register the worktree — gets the SAME canonical basename // (because of the `resolveRepoIdentityRoot` fix). Two entries // coexist with the same name but different paths; this is the // documented "silent basename collision" behavior, not an error. await registerRepo(worktreeDir, meta); // 3. Re-register the worktree. Pre-`hasCustomAlias`-fix this threw // `RegistryNameCollisionError` because the existing worktree // entry (`{name: 'repo', path: worktreeDir}`) was misclassified // as a custom alias by `hasCustomAlias`, fired the guard // against the canonical entry. With the fix it must complete // without throwing. await expect(registerRepo(worktreeDir, meta)).resolves.toBeDefined(); // Both registry entries should still be present and named // canonically. const entries = await listRegisteredRepos(); expect(entries).toHaveLength(2); const canonicalBasename = path.basename(tmpRepo.dbPath); for (const entry of entries) { expect(entry.name).toBe(canonicalBasename); } } finally { try { execSync(`git worktree remove -f "${worktreeDir}"`, { cwd: tmpRepo.dbPath }); } catch { // Falls through to recursive rm in afterEach. } } }); // Pinned by the third @claude review on PR #1296 (MEDIUM #2): the // `hasGitDir` gate inside `resolveRepoIdentityRoot` is the safeguard // that keeps the #1232/#1233 `--skip-git` behaviour working — an // arbitrary subdir under a parent git repo (no `.git` of its own) // must NOT collapse to the parent's canonical root, otherwise users // who analyze a subdir get the parent repo's basename in the // registry. The COOLIO `--skip-git` integration test in // `skip-git-cli.test.ts` already proves this end-to-end, but no // direct test sat at the `registerRepo` layer to guard the gate // against future refactors. This is that direct test. it('registerRepo on an arbitrary subdir under a git repo preserves the subdir basename (#1232 / #1233 gate)', async () => { execSync('git init -q', { cwd: tmpRepo.dbPath }); execSync('git config user.email "test@example.com"', { cwd: tmpRepo.dbPath }); execSync('git config user.name "Test"', { cwd: tmpRepo.dbPath }); execSync('git commit --allow-empty -q -m "initial"', { cwd: tmpRepo.dbPath }); // A subdir of the canonical checkout, NOT a worktree (no `.git` file // here — `mkdirSync` only). `resolveRepoIdentityRoot` must keep // returning this exact path (basename used for the registry name) // rather than collapsing to the parent repo's canonical root. const subdir = path.join(tmpRepo.dbPath, 'arbitrary-subdir'); await fs.mkdir(subdir, { recursive: true }); await registerRepo(subdir, meta); const entries = await listRegisteredRepos(); expect(entries).toHaveLength(1); // Registered name must be the SUBDIR's basename, NOT the parent // canonical repo's basename — the inverse of what worktree // collapse does. expect(entries[0].name).toBe('arbitrary-subdir'); expect(entries[0].name).not.toBe(path.basename(tmpRepo.dbPath)); }); });