GitNexus/gitnexus/test/unit/repo-manager.test.ts
Gergő Magyar 5f9648744c
fix(storage): strip credentials from remote URLs before they are persisted (#2914) (#2928)
`git config --get remote.origin.url` returns whatever the checkout was
configured with, and the HTTPS token form
`https://x-access-token:<token>@host/owner/repo` is how CI checkouts and
credential helpers routinely authenticate. `getRemoteUrl` kept that value
verbatim, so it reached `~/.gitnexus/registry.json` and the per-repo meta,
and MCP `list_repos` echoed it back — repository discovery doubled as
credential disclosure.

Three edges, one helper:

- `stripUrlCredentials` drops `user[:password]@` userinfo from http(s) URLs.
  `ssh://git@host/…` and SCP-like `git@host:owner/repo` are left alone: that
  is an SSH user name, not a secret, and rewriting it would repoint the
  sibling-clone fingerprint (#2054) for every registered repo.
- `getRemoteUrl` strips at capture, before the existing host lower-casing —
  that regex treats the whole `user:pass@host` span as the host, so it was
  also mangling the credential's case on the way to disk.
- The registry sanitizes on read AND write, so a `registry.json` (or a
  per-repo meta copied forward by a re-register) written by an older version
  is neither emitted nor rewritten with the credential still in it.

Also strips both URLs from the clone/remote mismatch error in
`assertRemoteMatchesRequestedUrl`, which is echoed to API callers and the
server log.

Sanitized values compare equal to a freshly captured remote on both sides,
so sibling matching, drift checks and `--name` inference are unchanged.


Claude-Session: https://claude.ai/code/session_01W8QYxYvd5ntikRUvpLZD5e

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 14:19:24 +01:00

1973 lines
81 KiB
TypeScript

/**
* 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,
reconcileMetadataFiles,
AnalysisNotFinalizedError,
INDEX_METADATA_FILE,
ensureGitNexusIgnored,
readRegistry,
loadCLIConfig,
registerRepo,
unregisterRepo,
removeBranchIndex,
adoptFlatBranchLabel,
listRegisteredRepos,
resolveRegistryEntry,
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/<slug> 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');
});
});
// ─── 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<ReturnType<typeof createTempDir>>;
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<ReturnType<typeof createTempDir>>;
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('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<typeof fs.open>[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<ReturnType<typeof createTempDir>>;
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<typeof fs.readFile>) => {
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<ReturnType<typeof createTempDir>>;
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 FRESHER legacy meta.json wins over a stale gitnexus.json (both rewritten)', async () => {
// The reproduced PR #2363 bug: an older binary re-analyzes and writes only
// meta.json AFTER gitnexus.json exists; the one-shot existence gate then
// ignored the fresher state forever (stale lastCommit won, dirty flag lost).
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('fresh-commit');
expect(legacy.lastCommit).toBe('fresh-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<ReturnType<typeof createTempDir>>;
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('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/<repo>`. 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<ReturnType<typeof createTempDir>>;
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 <alias> + 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<ReturnType<typeof createTempDir>>;
let tmpRepoA: Awaited<ReturnType<typeof createTempDir>>;
let tmpRepoB: Awaited<ReturnType<typeof createTempDir>>;
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('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('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<ReturnType<typeof createTempDir>>;
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
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<void> => {
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<ReturnType<typeof createTempDir>>;
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
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 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 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
});
// ─── 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<ReturnType<typeof createTempDir>>;
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 <rig>/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 <target>) ──────────
//
// 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('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.
// These tests use synthetic `RegistryEntry` fixtures (no disk I/O)
// because the guard is a pure string check — it must not depend on
// the paths existing.
describe('assertSafeStoragePath (#1003)', () => {
const prefix = process.platform === 'win32' ? 'D:\\' : '/tmp/';
const repoPath = `${prefix}projects${path.sep}my-repo`;
const base: Omit<RegistryEntry, 'storagePath'> = {
name: 'my-repo',
path: repoPath,
indexedAt: '2026-04-21T00:00:00.000Z',
lastCommit: 'deadbee',
};
it('accepts the canonical <repo>/.gitnexus storage path', () => {
const entry: RegistryEntry = {
...base,
storagePath: path.join(repoPath, '.gitnexus'),
};
expect(() => assertSafeStoragePath(entry)).not.toThrow();
});
it('rejects when storagePath equals the repo path itself (would delete the code)', () => {
const entry: RegistryEntry = {
...base,
storagePath: repoPath, // catastrophic: rm the working tree
};
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
});
it('rejects when storagePath is a parent of the repo path', () => {
const entry: RegistryEntry = {
...base,
storagePath: path.dirname(repoPath), // also catastrophic
};
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
});
it('rejects when storagePath is empty (path.resolve falls back to cwd)', () => {
const entry: RegistryEntry = {
...base,
storagePath: '', // path.resolve('') === process.cwd() — would rm cwd
};
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
});
it('rejects when storagePath points somewhere totally unrelated', () => {
const entry: RegistryEntry = {
...base,
storagePath: `${prefix}some${path.sep}other${path.sep}place`,
};
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
});
it('rejects when storagePath is a sibling .gitnexus (right basename, wrong parent)', () => {
const entry: RegistryEntry = {
...base,
storagePath: path.join(`${prefix}different${path.sep}repo`, '.gitnexus'),
};
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
});
it('UnsafeStoragePathError carries the original entry + expected + actual paths', () => {
const entry: RegistryEntry = {
...base,
storagePath: `${prefix}evil${path.sep}path`,
};
try {
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 `<repo>/.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', () => {
if (process.platform !== 'win32') return;
const entry: RegistryEntry = {
...base,
storagePath: path.join(repoPath.toUpperCase(), '.GITNEXUS'),
};
// Should accept because Windows paths are case-insensitive.
expect(() => assertSafeStoragePath(entry)).not.toThrow();
});
});
// ─── 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<ReturnType<typeof createTempDir>>;
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
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));
});
});