mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
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:
parent
c0ebd160c2
commit
1f4235a343
5 changed files with 531 additions and 0 deletions
|
|
@ -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')
|
||||
|
|
|
|||
90
gitnexus/src/cli/remove.ts
Normal file
90
gitnexus/src/cli/remove.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue