feat(cli): gitnexus remove <target> to unindex a registered repo by name or path (#664)

Add a `remove` CLI command that deletes the `.gitnexus/` index AND
unregisters a repo from the global registry (~/.gitnexus/registry.json),
addressing the lifecycle gap flagged in #664: previously users had to
cd into the repo to run `clean`, and there was no path-based or
alias-based remove for an already-deleted working tree.

- New command `gitnexus remove <target> [-f|--force]`. `<target>` is
  alias / basename-derived name / remote-inferred name / absolute path.
- New helper `resolveRegistryEntry(entries, target)` in repo-manager.ts
  with path > name precedence; throws RegistryNotFoundError or
  RegistryAmbiguousTargetError (typed, `kind`-discriminated).
- Atomicity mirrors `clean`: fs.rm first, then unregisterRepo; partial
  failures self-heal on next `listRegisteredRepos({ validate: true })`.
- Idempotent on unknown targets (exit 0 with warning) per the #664
  spec: "behave atomically and idempotently so retries are safe".
- `--force` uses `clean`-style confirmation-skip semantics — distinct
  from `analyze --force` (pipeline re-index); here there is no pipeline
  so no conflation.
- 7 new unit tests cover resolver precedence, case sensitivity,
  ambiguity, and not-found hints; 2 integration tests cover the real
  CLI -> registry -> filesystem chain including the --allow-duplicate-name
  (#829) ambiguity case.
This commit is contained in:
azizur1992 2026-04-21 00:29:59 +01:00
parent c0ebd160c2
commit 1f4235a343
5 changed files with 531 additions and 0 deletions

View file

@ -83,6 +83,15 @@ program
.option('--all', 'Clean all indexed repos')
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
program
.command('remove <target>')
.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')

View file

@ -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);
}
};

View file

@ -430,6 +430,115 @@ export const unregisterRepo = async (repoPath: string): Promise<void> => {
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 <target>`
* 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 <name>` errors.
const nameCounts = new Map<string, number>();
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.

View file

@ -345,6 +345,177 @@ describe('CLI end-to-end', () => {
}, 360000); // 6-min outer budget (4 × ~60s analyze calls + fixture setup)
});
// ─── gitnexus remove <target> (#664) ─────────────────────────────
//
// End-to-end regression guard for the remove command:
// 1. `remove <alias>` without --force is a dry-run (exit 0, preserves state)
// 2. `remove <alias> --force` deletes the .gitnexus/ directory
// AND unregisters from the global registry
// 3. `remove <unknown>` is idempotent (exit 0 with a warning)
// 4. `remove <ambiguous>` (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 <target> (#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);

View file

@ -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 <target>) ──────────
//
// The resolver is a pure function over a `RegistryEntry[]` snapshot, so
// these tests build synthetic entries inline and do NOT touch
// ~/.gitnexus. No GITNEXUS_HOME sandboxing needed. This also means the
// tests are platform-portable on Windows where realpath semantics on
// tmpdirs can diverge between runs (see the #955 CI pivot).
describe('resolveRegistryEntry (#664)', () => {
// A well-known synthetic registry with two same-name entries (which
// can only exist in reality after `--allow-duplicate-name` — #829) and
// one unique-name entry. Path prefixes differ across platforms so the
// tests stay meaningful regardless of `process.platform`.
const prefix = process.platform === 'win32' ? 'D:\\' : '/tmp/';
const pathA = `${prefix}projects${path.sep}gnx-a${path.sep}app`;
const pathB = `${prefix}projects${path.sep}gnx-b${path.sep}app`;
const pathW = `${prefix}work${path.sep}website`;
const entries: RegistryEntry[] = [
{
name: 'app',
path: pathA,
storagePath: `${pathA}${path.sep}.gitnexus`,
indexedAt: '2026-04-18T00:00:00.000Z',
lastCommit: 'aaaaaaa',
},
{
name: 'app',
path: pathB,
storagePath: `${pathB}${path.sep}.gitnexus`,
indexedAt: '2026-04-18T00:00:00.000Z',
lastCommit: 'bbbbbbb',
},
{
name: 'website',
path: pathW,
storagePath: `${pathW}${path.sep}.gitnexus`,
indexedAt: '2026-04-18T00:00:00.000Z',
lastCommit: 'ccccccc',
},
];
it('resolves by absolute path to the exact entry (path tier beats name tier)', () => {
const hit = resolveRegistryEntry(entries, pathA);
expect(hit).toBe(entries[0]);
expect(hit.path).toBe(pathA);
const hit2 = resolveRegistryEntry(entries, pathB);
expect(hit2).toBe(entries[1]);
expect(hit2.path).toBe(pathB);
});
it('resolves by unique name to the only matching entry', () => {
const hit = resolveRegistryEntry(entries, 'website');
expect(hit).toBe(entries[2]);
expect(hit.name).toBe('website');
});
it('name match is case-insensitive', () => {
expect(resolveRegistryEntry(entries, 'WEBSITE')).toBe(entries[2]);
expect(resolveRegistryEntry(entries, 'Website')).toBe(entries[2]);
});
it('path match is case-insensitive on Windows only', () => {
if (process.platform !== 'win32') {
// On POSIX, a differently-cased path must NOT match. Verify by
// lower-casing a mixed-case copy of pathW and expecting a miss.
const upper = pathW.toUpperCase();
expect(() => resolveRegistryEntry(entries, upper)).toThrow(RegistryNotFoundError);
return;
}
const upper = pathA.toUpperCase();
const hit = resolveRegistryEntry(entries, upper);
expect(hit).toBe(entries[0]);
});
it('throws RegistryAmbiguousTargetError when name matches multiple entries', () => {
// Two 'app' entries exist only because of --allow-duplicate-name
// (#829). The resolver MUST refuse to guess.
expect(() => resolveRegistryEntry(entries, 'app')).toThrow(RegistryAmbiguousTargetError);
try {
resolveRegistryEntry(entries, 'app');
} catch (e) {
expect(e).toBeInstanceOf(RegistryAmbiguousTargetError);
const err = e as RegistryAmbiguousTargetError;
expect(err.kind).toBe('RegistryAmbiguousTargetError');
expect(err.target).toBe('app');
expect(err.matches).toHaveLength(2);
// Error message must include both paths so the CLI can surface
// them without string-matching on `.message`.
expect(err.message).toContain(pathA);
expect(err.message).toContain(pathB);
}
});
it('throws RegistryNotFoundError when no entry matches', () => {
expect(() => resolveRegistryEntry(entries, 'nonexistent')).toThrow(RegistryNotFoundError);
try {
resolveRegistryEntry(entries, 'nonexistent');
} catch (e) {
expect(e).toBeInstanceOf(RegistryNotFoundError);
const err = e as RegistryNotFoundError;
expect(err.kind).toBe('RegistryNotFoundError');
expect(err.target).toBe('nonexistent');
// availableNames is disambiguated: 'app' appears twice, so both
// `app (path)` variants are included; 'website' is unique so it
// stays plain — matches the resolveRepo disambiguation shape.
expect(err.availableNames).toContain('website');
expect(err.availableNames.some((n) => n.startsWith('app ('))).toBe(true);
// Error message surfaces the hint.
expect(err.message).toContain('website');
}
});
it('throws RegistryNotFoundError with "no repositories registered" hint when registry is empty', () => {
try {
resolveRegistryEntry([], 'anything');
} catch (e) {
expect(e).toBeInstanceOf(RegistryNotFoundError);
const err = e as RegistryNotFoundError;
expect(err.availableNames).toEqual([]);
expect(err.message).toContain('No repositories are currently registered');
}
});
it('path match wins over name match (never ambiguous)', () => {
// Construct a pathological fixture where a registry entry's NAME
// happens to equal another entry's PATH. The path tier must win
// without triggering ambiguity.
const weird: RegistryEntry[] = [
{ ...entries[2] }, // 'website' at pathW
{
name: pathW, // degenerate: name equals another entry's path
path: `${prefix}elsewhere${path.sep}odd`,
storagePath: `${prefix}elsewhere${path.sep}odd${path.sep}.gitnexus`,
indexedAt: '2026-04-18T00:00:00.000Z',
lastCommit: 'ddddddd',
},
];
const hit = resolveRegistryEntry(weird, pathW);
// Must match the entry whose PATH is pathW, not the one whose NAME
// is pathW — because Tier 1 runs before Tier 2 and finds the path
// match first.
expect(hit.path).toBe(pathW);
expect(hit.name).toBe('website');
});
});