GitNexus/gitnexus/test/unit/repo-manager.test.ts
Yayler 3aa62be717
feat: add gitnexus auto-sync for scheduled remote clone and analyze (#2493)
* adds an opt-in auto sync and analysis loop for GitNexus

* adds an opt-in auto sync and analysis loop for GitNexus,gitnexus watch [init|start|restart|stop|status]

* adds an opt-in auto sync and analysis loop for GitNexus,gitnexus watch [init|start|restart|stop|status]

* fix: address PR review cleanup

* Prettier code style

* merge main

* fix(watch): protect local repos and cancel active analysis

* fix(watch): harden auto-sync lifecycle and locking

- validate watch process identity before lifecycle operations\n- serialize registry, analysis, and LadybugDB access with recoverable locks\n- harden clone paths, symlinks, hooks, quarantine, and worker timeouts\n- install procps in the CLI image for reliable Docker watch control\n- add focused regression coverage for lifecycle, locks, clone, and registry behavior

* update agents & claude md

* merge main

* fix(watch): harden auto-sync lifecycle

* fix(watch): normalize SSH repo identity paths

* fix(watch): normalize SSH repo identity paths

* fix(watch): safely cancel analysis across platforms

* fix(auto-sync): close worker and group sync failure paths

* fix(auto-sync): drop retired allowStale from group sync

allowStale was removed from SyncOptions, which broke typecheck and CI on this PR.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(watch): satisfy prefer-const and Prettier in auto-sync

The watch timers are assigned exactly once, so prefer-const rejected the
deferred `let` declarations. They are only read from `stop()` and the control
poll, both of which run after the assignments, so binding them at creation is
safe and drops the now-dead undefined guards.

Remaining files are formatting only.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(watch): make lock identity absolute and stop three fail-open paths

Lock owner identity was rendered by `ps -o lstart=` through localtime and the
active locale, so the same live process produced a different string under a
different TZ. A mismatch reads as PID reuse, so one daemon could reclaim a
mutex another still held. Pin TZ=UTC and LC_ALL=C.

The owner record also carried no hostname, so a holder on another machine was
judged by this kernel's view of its PID — always "stale" — and its lock stolen
whenever GITNEXUS_HOME is a shared volume. Record and compare the hostname, as
the index lock already does.

Ownership verification threw unconditionally on win32, which is reached once
per project per tick, so watch reported `running` and then failed every repo
forever. POSIX uid/mode cannot be checked there; skip those two assertions and
keep the dangerous-root, symlink, containment and internal-root guards.

Also: quarantine sweep now refuses a symlinked root instead of deleting
through it; an unreadable state file propagates instead of being rewritten as
empty state, which used to erase every repo's analyzed commit and failure
count; a failed staging cleanup no longer strands a published lock with no
release handle; and the concurrency runner settles every worker before
surfacing a failure so cancellation cannot orphan a live analyze fork.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(watch): land the deferred review findings

Six findings that were deferred from the review backlog, plus the docs they
change.

Worker heap: admission allowed `floor(availableMemoryGB / 2)` slots while every
fork was handed the whole machine's heap cap, so the budget meant nothing as
soon as an operator raised max_concurrency. Divide the cap by the repos
actually analyzed in parallel. The default single-project path is unchanged.

Registration: the parent registered without a branch, so it always took the
primary/flat arm and relabelled a pinned branch entry on the branch-fallback
path. Reproduce the worker's own resolveBranchPlacement decision instead.

Cancellation: requestCancellation cleared the only timer and settled nothing,
so a worker wedged past its safe point left the promise pending forever,
wedging activeRun and hanging `watch stop`. Add a 5s grace after which the
parent stops waiting and releases the IPC channel's hold on its event loop.
The child is still never killed — it may be inside native work.

overwrite_local_changes: `checkout --force` rewrites tracked files only, so
untracked sources survived and were indexed as if they came from the remote.
`git clean -fd -e /.gitnexus` after checkout; no -x/-X, so ignored paths and
GitNexus's own storage survive.

Quarantine: age alone never bounds a repo that fails every tick, since each
partial clone is younger than the retention window. Keep the five newest per
repo.

Validation: repo_git_timeout is now bounded by the lesser of an hour and the
sync interval, which is also the guard for the bare-number-means-seconds slip
(`600000` meant ~7 days and cleared the timer ceiling). And the remote URL's
final segment is validated at config load rather than failing once per tick
inside the sync loop.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(watch): release an errored worker, and stop rejecting dotted repo names

Three findings from the latest review pass.

The 'error' handler settles immediately rather than waiting out the grace, so
cleanup() clears the grace timer that would otherwise have released the child.
An errored IPC channel does not mean the worker stopped, so release it on that
path too — still no kill.

The traversal guard tested the raw path for '..', which also rejected an
ordinary name like owner/foo..bar that the repository-name rule accepts.
Traversal is a whole segment, so test segments.

The heap-cap test left two runs and their real timers pending; it now stubs
timers and settles both promises. Registration coverage now pins the branch
slot rather than leaving it implicit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(watch): validate namespace segments and pin the stopped process identity

Replacing the raw-string `..` test with a per-segment one dropped a guard: a
segment like `..\..\outside` is not literally `..`, so it passed, and those
segments build the clone path — on Windows the backslashes are separators.
Hold every namespace segment to the same charset as the repo name, which keeps
a separator out of a segment while still allowing an ordinary `foo..bar`. The
final segment keeps its own check so a bad repo name keeps its own message.

The stop wait polled liveness by pid alone, so a pid reused mid-wait would
have it wait on an unrelated process and then report the watch stopped. Compare
the process start time recorded for the owner, which also returns sooner.

Registration now omits `branch` for a primary index instead of passing it as
undefined, so that call keeps the shape it had before this branch.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): ship auto-sync as the remote daemon, reserve gitnexus watch.

Keep analyze --watch for local incremental re-index and stop the top-level watch verb from starting a clone/pull loop.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auto-sync): reject invalid branch refs and verify status identity (#2493)

Reject leading slashes and per-component trailing dots in configured branches, and verify the live watch owner before trusting a stored error status.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auto-sync): reject ownerIds that can escape the watch directory (#2493)

Stop interpolating a tampered ownerId into the stop-request filename; only basename-safe values are treated as owners.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auto-sync): recognize auto-sync in the watch-process identity check (#2493)

Stop/status were still looking for a standalone watch token after the command rename, so a live gitnexus auto-sync start process would be refused as unrelated.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(auto-sync): reject boolean max_concurrency instead of coercing it to 1 (#2493)

Number(true) is 1, so a YAML boolean would have passed the integer check and silently meant one worker.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auto-sync): swallow status errors in the watch finally path (#2493)

An uncaught updateStatus rejection in finally became an unhandled
rejection. Skip the clone-root symlink test on Windows, where directory
symlinks need privileges. Align the group-lock comment with fail-closed
registry timeouts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auto-sync): catch cancelling status-write failures (#2493)

Fire-and-forget updateStatus('cancelling') could become an unhandled
rejection, the same class as the finally-path status write.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auto-sync): ignore queued interval ticks after stop (#2493)

clearInterval does not cancel a timer callback already queued. Guard
runSafely on stopping so shutdown cannot start a new un-cancellable run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auto-sync): report stored watch status timestamps (#2493)

status should show when the watch last entered a state, not when the
CLI queried it. The failure-count test still expects 1 after a new
commit resets the streak; rename it so that reset is explicit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(auto-sync): apply prettier to starter status logger (#2493)

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: weiyf <weiyf3634@163.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-02 19:40:18 +01:00

2024 lines
83 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,
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/<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('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<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('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('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('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.
// 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));
});
});