diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 2b54f04f3..fe19ba753 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -83,6 +83,15 @@ program .option('--all', 'Clean all indexed repos') .action(createLazyAction(() => import('./clean.js'), 'cleanCommand')); +program + .command('remove ') + .description( + 'Delete the GitNexus index for a registered repo (by alias, name, or absolute path). ' + + 'Unlike `clean`, does not require being inside the repo. Idempotent on unknown targets.', + ) + .option('-f, --force', 'Skip confirmation prompt') + .action(createLazyAction(() => import('./remove.js'), 'removeCommand')); + program .command('wiki [path]') .description('Generate repository wiki from knowledge graph') diff --git a/gitnexus/src/cli/remove.ts b/gitnexus/src/cli/remove.ts new file mode 100644 index 000000000..1100b3e32 --- /dev/null +++ b/gitnexus/src/cli/remove.ts @@ -0,0 +1,90 @@ +/** + * Remove Command (#664) + * + * Delete the `.gitnexus/` index for a registered repo and unregister it + * from the global registry (~/.gitnexus/registry.json). The target is + * identified by alias / basename-derived name / remote-inferred name / + * absolute path — no `--repo` flag, just a positional argument so the + * destructive-command ergonomics match `clean` (which is also + * destructive but scoped to `process.cwd()`). + * + * Compared to `clean`: + * - `clean` acts on the repo discovered by walking up from cwd. + * - `remove` acts on any registered repo identified by name or path. + * + * Behaviour notes: + * - Idempotent on unknown targets: exits 0 with a warning so that + * `remove X && analyze Y` keeps working in scripts. Per #664: + * "behave atomically and idempotently so retries are safe". + * - Atomic order mirrors `clean`: fs.rm FIRST, then unregister. A + * partial failure leaves the registry pointing at a missing dir + * (recoverable by `listRegisteredRepos({ validate: true })` on + * next read) rather than the opposite, which would orphan + * .gitnexus/ directories on disk. + * - `-f` / `--force` matches the confirmation-skip semantics of + * `clean -f`. (Distinct from `analyze --force`, which re-indexes; + * here there is no pipeline, so no conflation.) + */ + +import fs from 'fs/promises'; +import { + readRegistry, + resolveRegistryEntry, + unregisterRepo, + RegistryNotFoundError, + RegistryAmbiguousTargetError, +} from '../storage/repo-manager.js'; + +export const removeCommand = async (target: string, options?: { force?: boolean }) => { + // Read the registry snapshot once and pass it to the resolver — this + // lets us render the "before" state in the dry-run path without a + // second disk read. + const entries = await readRegistry(); + + let entry; + try { + entry = resolveRegistryEntry(entries, target); + } catch (err) { + if (err instanceof RegistryNotFoundError) { + // Idempotent: missing target is a no-op warning, not an error. + // The `availableNames` hint comes from the error itself so users + // can see what they might have meant. + console.warn(`Nothing to remove: ${err.message}`); + return; + } + if (err instanceof RegistryAmbiguousTargetError) { + // Duplicate aliases are allowed via --allow-duplicate-name (#829); + // refuse to guess which one the user meant — surface the full list + // and exit non-zero so scripts don't silently pick the wrong repo. + console.error(`Error: ${err.message}`); + process.exit(1); + } + throw err; + } + + // Confirmation gate — same shape as `clean`. Default is a dry-run + // that describes what would be deleted; `--force` actually deletes. + if (!options?.force) { + console.log(`This will delete the GitNexus index for: ${entry.name}`); + console.log(` Path: ${entry.path}`); + console.log(` Storage: ${entry.storagePath}`); + console.log('\nRun with --force to confirm deletion.'); + return; + } + + // Deletion order: fs.rm first, then unregister. If fs.rm fails mid-way, + // the registry entry stays so the user can retry. If fs.rm succeeds but + // unregister throws (e.g. ENOSPC on registry write), the entry becomes + // orphaned — `listRegisteredRepos({ validate: true })` prunes those on + // next read, so the failure is self-healing. + try { + await fs.rm(entry.storagePath, { recursive: true, force: true }); + await unregisterRepo(entry.path); + console.log(`Removed: ${entry.name}`); + console.log(` Path: ${entry.path}`); + console.log(` Storage: ${entry.storagePath}`); + } catch (err) { + console.error(`Failed to remove ${entry.name}:`, err); + process.exit(1); + } +}; diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 1b151cec1..e4932e234 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -430,6 +430,115 @@ export const unregisterRepo = async (repoPath: string): Promise => { await writeRegistry(filtered); }; +/** + * Thrown by {@link resolveRegistryEntry} when no registered repo matches + * the caller's target string (by alias, basename, remote-inferred name, + * or resolved path). CLI callers that want idempotent "remove" semantics + * should catch this and exit 0 with a warning; non-idempotent callers + * (e.g. MCP tools) can surface the error directly. + */ +export class RegistryNotFoundError extends Error { + readonly kind = 'RegistryNotFoundError' as const; + constructor( + public readonly target: string, + public readonly availableNames: string[], + ) { + const hint = + availableNames.length > 0 + ? ` Available: ${availableNames.join(', ')}.` + : ' No repositories are currently registered.'; + super(`No registered repo matches "${target}".${hint}`); + this.name = 'RegistryNotFoundError'; + } +} + +/** + * Thrown by {@link resolveRegistryEntry} when the target string matches + * the `name` of two or more entries — only possible when the user + * previously registered duplicates via `analyze --name X + * --allow-duplicate-name` (#829). The error carries enough information + * for the caller to render an actionable disambiguation hint without + * string-matching on `.message`. + * + * `kind` is a string literal discriminant (same pattern as + * {@link RegistryNameCollisionError}) so callers can narrow via + * `err.kind === 'RegistryAmbiguousTargetError'` without importing the + * class. + */ +export class RegistryAmbiguousTargetError extends Error { + readonly kind = 'RegistryAmbiguousTargetError' as const; + constructor( + public readonly target: string, + public readonly matches: RegistryEntry[], + ) { + const listing = matches.map((m) => ` - ${m.name} (${m.path})`).join('\n'); + super( + `Multiple registered repos match "${target}":\n${listing}\n` + + `Pass the absolute path instead to disambiguate.`, + ); + this.name = 'RegistryAmbiguousTargetError'; + } +} + +/** + * Resolve a user-supplied target string (from `gitnexus remove ` + * or equivalent MCP tool argument) to a single registry entry. + * + * Match precedence (first hit wins, subsequent tiers are only tried if + * the prior tier produces zero matches): + * 1. Exact resolved-path match (Windows: case-insensitive). + * Paths are unique by registry construction, so a path match can + * never be ambiguous. + * 2. Exact `name` match (case-insensitive). If ≥ 2 entries share the + * name — only possible via `--allow-duplicate-name` (#829) — + * throws {@link RegistryAmbiguousTargetError}. + * + * No fuzzy / partial matching — unambiguous, scriptable behaviour is + * more important than convenience for destructive commands. + * + * Throws {@link RegistryNotFoundError} if no entry matches. + * + * `entries` is passed in (rather than re-read) so callers that already + * hold the registry snapshot (e.g. to print a "before" state) can avoid + * a second disk read, and so tests can inject fixtures without touching + * `GITNEXUS_HOME`. + */ +export const resolveRegistryEntry = (entries: RegistryEntry[], target: string): RegistryEntry => { + // Tier 1: path match. Normalise both sides the same way + // `registerRepo` / `unregisterRepo` do. + const resolvedTarget = path.resolve(target); + const pathMatch = entries.find((e) => { + const a = path.resolve(e.path); + const b = resolvedTarget; + return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b; + }); + if (pathMatch) return pathMatch; + + // Tier 2: name match. Case-insensitive on all platforms — registry + // name collisions are already filtered case-insensitively in + // `registerRepo`, so "APP" vs "app" are considered the same key. + const targetLower = target.toLowerCase(); + const nameMatches = entries.filter((e) => e.name.toLowerCase() === targetLower); + if (nameMatches.length === 1) return nameMatches[0]; + if (nameMatches.length > 1) { + throw new RegistryAmbiguousTargetError(target, nameMatches); + } + + // Tier 3: miss. Build the available-names hint ONCE; resolveRepo-style + // disambiguated labels (`app (/path)`) are applied when the same name + // appears in multiple entries so the user sees the same hint shape as + // `-r ` errors. + const nameCounts = new Map(); + for (const e of entries) { + const key = e.name.toLowerCase(); + nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1); + } + const availableNames = entries.map((e) => + (nameCounts.get(e.name.toLowerCase()) ?? 0) > 1 ? `${e.name} (${e.path})` : e.name, + ); + throw new RegistryNotFoundError(target, availableNames); +}; + /** * List all registered repos from the global registry. * Optionally validates that each entry's .gitnexus/ still exists. diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index 844e3978d..618320c89 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -345,6 +345,177 @@ describe('CLI end-to-end', () => { }, 360000); // 6-min outer budget (4 × ~60s analyze calls + fixture setup) }); + // ─── gitnexus remove (#664) ───────────────────────────── + // + // End-to-end regression guard for the remove command: + // 1. `remove ` without --force is a dry-run (exit 0, preserves state) + // 2. `remove --force` deletes the .gitnexus/ directory + // AND unregisters from the global registry + // 3. `remove ` is idempotent (exit 0 with a warning) + // 4. `remove ` (two entries share the alias via + // --allow-duplicate-name) exits 1 with a disambiguation hint + // and leaves the registry unchanged. + // + // Every assertion reads the real registry.json on disk, so any + // regression in remove.ts → resolveRegistryEntry → unregisterRepo + // will surface here. + describe('remove (#664)', () => { + it('dry-run lists, --force deletes, missing target is a no-op warning', () => { + const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-home-remove-')); + const repoA = makeMiniRepoCopy('remove-me', 'gn-rm-a-'); + const parentA = path.dirname(repoA); + + try { + // Index the repo under a custom alias so we can target it by + // name below. `--name` guarantees a stable alias regardless of + // how the host resolves the basename/remote-inferred name. + const r1 = runCliWithEnv( + ['analyze', '--name', 'alias-a'], + repoA, + { GITNEXUS_HOME: gnHome }, + 60000, + ); + if (r1.status === null) return; + expect( + r1.status, + [`analyze exited with ${r1.status}`, `stdout: ${r1.stdout}`, `stderr: ${r1.stderr}`].join( + '\n', + ), + ).toBe(0); + + const registryPath = path.join(gnHome, 'registry.json'); + const afterIndex = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); + expect(afterIndex).toHaveLength(1); + expect(afterIndex[0].name).toBe('alias-a'); + // Storage dir must exist before remove so we can assert its + // disappearance below. + const storagePath = afterIndex[0].storagePath; + expect(fs.existsSync(storagePath)).toBe(true); + + // Dry-run: must NOT delete. Use parentA as cwd so the test + // never runs with the to-be-removed storage dir as its cwd. + const r2 = runCliWithEnv(['remove', 'alias-a'], parentA, { GITNEXUS_HOME: gnHome }, 15000); + if (r2.status === null) return; + expect(r2.status).toBe(0); + const r2Output = `${r2.stdout}${r2.stderr}`; + expect(r2Output).toMatch(/Run with --force/i); + expect(fs.existsSync(storagePath)).toBe(true); + // Registry still has the entry. + expect(JSON.parse(fs.readFileSync(registryPath, 'utf-8'))).toHaveLength(1); + + // --force: must delete storage AND unregister. + const r3 = runCliWithEnv( + ['remove', 'alias-a', '--force'], + parentA, + { GITNEXUS_HOME: gnHome }, + 15000, + ); + if (r3.status === null) return; + expect( + r3.status, + [ + `remove --force exited with ${r3.status}`, + `stdout: ${r3.stdout}`, + `stderr: ${r3.stderr}`, + ].join('\n'), + ).toBe(0); + expect(`${r3.stdout}${r3.stderr}`).toMatch(/Removed/i); + expect(fs.existsSync(storagePath)).toBe(false); + expect(JSON.parse(fs.readFileSync(registryPath, 'utf-8'))).toHaveLength(0); + + // Idempotent: removing the same alias AGAIN must exit 0 with a + // warning (so `remove X && analyze Y` keeps working in scripts). + const r4 = runCliWithEnv(['remove', 'alias-a'], parentA, { GITNEXUS_HOME: gnHome }, 15000); + if (r4.status === null) return; + expect(r4.status).toBe(0); + expect(`${r4.stdout}${r4.stderr}`).toMatch(/Nothing to remove/i); + } finally { + fs.rmSync(gnHome, { recursive: true, force: true }); + fs.rmSync(parentA, { recursive: true, force: true }); + } + }, 180000); // 3-min outer budget (1 × ~60s analyze + 3 × fast remove calls) + + it('ambiguous target (two entries share alias via --allow-duplicate-name) errors without mutating registry', () => { + const gnHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-home-rm-amb-')); + const repoA = makeMiniRepoCopy('dup', 'gn-dup-a-'); + const repoB = makeMiniRepoCopy('dup', 'gn-dup-b-'); + const parentA = path.dirname(repoA); + const parentB = path.dirname(repoB); + + try { + // Two repos registered under the same alias — only possible via + // --allow-duplicate-name (#829). + const r1 = runCliWithEnv( + ['analyze', '--name', 'shared'], + repoA, + { GITNEXUS_HOME: gnHome }, + 60000, + ); + if (r1.status === null) return; + expect(r1.status).toBe(0); + + const r2 = runCliWithEnv( + ['analyze', '--name', 'shared', '--allow-duplicate-name'], + repoB, + { GITNEXUS_HOME: gnHome }, + 60000, + ); + if (r2.status === null) return; + expect(r2.status).toBe(0); + + const registryPath = path.join(gnHome, 'registry.json'); + const before = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); + expect(before).toHaveLength(2); + + // `remove shared` must refuse to guess — exit 1, disambiguation hint. + const r3 = runCliWithEnv( + ['remove', 'shared', '--force'], + parentA, + { GITNEXUS_HOME: gnHome }, + 15000, + ); + if (r3.status === null) return; + expect(r3.status).toBe(1); + const r3Output = `${r3.stdout}${r3.stderr}`; + expect(r3Output).toMatch(/Multiple registered repos match/i); + // Both paths must be surfaced in the hint so the user knows + // which ones to disambiguate between. + expect(r3Output).toMatch(/dup/); + + // Registry unchanged — the failed resolution must NOT have + // mutated state. + const after = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); + expect(after).toHaveLength(2); + + // And path-based remove still works: pass the absolute path of + // repoA and it resolves unambiguously. + const r4 = runCliWithEnv( + ['remove', repoA, '--force'], + parentA, + { GITNEXUS_HOME: gnHome }, + 15000, + ); + if (r4.status === null) return; + expect( + r4.status, + [ + `remove-by-path exited with ${r4.status}`, + `stdout: ${r4.stdout}`, + `stderr: ${r4.stderr}`, + ].join('\n'), + ).toBe(0); + const finalEntries = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); + expect(finalEntries).toHaveLength(1); + // The survivor is repoB (its path stays in the registry). + expect(path.basename(finalEntries[0].path)).toBe('dup'); + } finally { + fs.rmSync(gnHome, { recursive: true, force: true }); + fs.rmSync(parentA, { recursive: true, force: true }); + fs.rmSync(parentB, { recursive: true, force: true }); + } + }, 240000); // 4-min outer budget (2 × ~60s analyze + 2 × fast remove) + }); + describe('unhappy path', () => { it('exits with error when no command is given', () => { const result = runCliRaw([], MINI_REPO); diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts index 83827fc15..429ecdbc3 100644 --- a/gitnexus/test/unit/repo-manager.test.ts +++ b/gitnexus/test/unit/repo-manager.test.ts @@ -15,7 +15,11 @@ import { loadCLIConfig, registerRepo, listRegisteredRepos, + resolveRegistryEntry, RegistryNameCollisionError, + RegistryNotFoundError, + RegistryAmbiguousTargetError, + type RegistryEntry, type RepoMeta, } from '../../src/storage/repo-manager.js'; import { parseRepoNameFromUrl, getInferredRepoName } from '../../src/storage/git.js'; @@ -453,3 +457,151 @@ describe('getInferredRepoName + registerRepo (#979 — git remote inference)', ( } }); }); + +// ─── resolveRegistryEntry (#664 — gitnexus remove ) ────────── +// +// The resolver is a pure function over a `RegistryEntry[]` snapshot, so +// these tests build synthetic entries inline and do NOT touch +// ~/.gitnexus. No GITNEXUS_HOME sandboxing needed. This also means the +// tests are platform-portable on Windows where realpath semantics on +// tmpdirs can diverge between runs (see the #955 CI pivot). + +describe('resolveRegistryEntry (#664)', () => { + // A well-known synthetic registry with two same-name entries (which + // can only exist in reality after `--allow-duplicate-name` — #829) and + // one unique-name entry. Path prefixes differ across platforms so the + // tests stay meaningful regardless of `process.platform`. + const prefix = process.platform === 'win32' ? 'D:\\' : '/tmp/'; + const pathA = `${prefix}projects${path.sep}gnx-a${path.sep}app`; + const pathB = `${prefix}projects${path.sep}gnx-b${path.sep}app`; + const pathW = `${prefix}work${path.sep}website`; + + const entries: RegistryEntry[] = [ + { + name: 'app', + path: pathA, + storagePath: `${pathA}${path.sep}.gitnexus`, + indexedAt: '2026-04-18T00:00:00.000Z', + lastCommit: 'aaaaaaa', + }, + { + name: 'app', + path: pathB, + storagePath: `${pathB}${path.sep}.gitnexus`, + indexedAt: '2026-04-18T00:00:00.000Z', + lastCommit: 'bbbbbbb', + }, + { + name: 'website', + path: pathW, + storagePath: `${pathW}${path.sep}.gitnexus`, + indexedAt: '2026-04-18T00:00:00.000Z', + lastCommit: 'ccccccc', + }, + ]; + + it('resolves by absolute path to the exact entry (path tier beats name tier)', () => { + const hit = resolveRegistryEntry(entries, pathA); + expect(hit).toBe(entries[0]); + expect(hit.path).toBe(pathA); + + const hit2 = resolveRegistryEntry(entries, pathB); + expect(hit2).toBe(entries[1]); + expect(hit2.path).toBe(pathB); + }); + + it('resolves by unique name to the only matching entry', () => { + const hit = resolveRegistryEntry(entries, 'website'); + expect(hit).toBe(entries[2]); + expect(hit.name).toBe('website'); + }); + + it('name match is case-insensitive', () => { + expect(resolveRegistryEntry(entries, 'WEBSITE')).toBe(entries[2]); + expect(resolveRegistryEntry(entries, 'Website')).toBe(entries[2]); + }); + + it('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'); + }); +});