mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
117 lines
4.9 KiB
TypeScript
117 lines
4.9 KiB
TypeScript
/**
|
|
* Remove Command (#664)
|
|
*
|
|
* Delete the `.gitnexus/` index directory for a registered repo (including
|
|
* both metadata filenames — gitnexus.json and its legacy meta.json mirror —
|
|
* which live inside it) 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 { logger } from '../core/logger.js';
|
|
import { cliError } from './cli-message.js';
|
|
import { t } from './i18n/index.js';
|
|
import {
|
|
readRegistry,
|
|
resolveRegistryEntry,
|
|
assertSafeStoragePath,
|
|
unregisterRepo,
|
|
RegistryNotFoundError,
|
|
RegistryAmbiguousTargetError,
|
|
UnsafeStoragePathError,
|
|
} 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.
|
|
logger.warn(t('remove.nothingToRemove', { message: 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.
|
|
cliError(t('common.error', { message: 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(t('remove.deleteTarget', { name: entry.name }));
|
|
console.log(` ${t('common.path')}: ${entry.path}`);
|
|
console.log(` ${t('common.storage')}: ${entry.storagePath}`);
|
|
console.log(`\n${t('common.runForceConfirm')}`);
|
|
return;
|
|
}
|
|
|
|
// Safety guard (#1003 review — @magyargergo): refuse to proceed if
|
|
// the registry entry's `storagePath` isn't the canonical
|
|
// `<entry.path>/.gitnexus` subfolder. `~/.gitnexus/registry.json` is
|
|
// user-writable, so a corrupted or hand-edited entry could point
|
|
// storagePath at the repo root, an empty string (→ cwd), a parent
|
|
// dir, or anywhere else; `fs.rm(recursive: true, force: true)` on
|
|
// any of those would be a runtime disaster. Bail before touching
|
|
// disk, with an actionable hint for recovering a broken registry.
|
|
try {
|
|
assertSafeStoragePath(entry);
|
|
} catch (err) {
|
|
if (err instanceof UnsafeStoragePathError) {
|
|
cliError(t('common.error', { message: err.message }));
|
|
process.exit(1);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// 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(t('remove.removed', { name: entry.name }));
|
|
console.log(` ${t('common.path')}: ${entry.path}`);
|
|
console.log(` ${t('common.storage')}: ${entry.storagePath}`);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
cliError(t('remove.failed', { name: entry.name, message: msg }), { err });
|
|
process.exit(1);
|
|
}
|
|
};
|