gitnexus 1.6.3-rc.30 → 1.6.3-rc.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/clean.js +19 -1
- package/dist/cli/index.js +6 -0
- package/dist/cli/remove.d.ts +30 -0
- package/dist/cli/remove.js +99 -0
- package/dist/core/group/cross-impact.js +7 -18
- package/dist/storage/repo-manager.d.ts +133 -0
- package/dist/storage/repo-manager.js +249 -5
- package/package.json +1 -1
package/dist/cli/clean.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Also unregisters it from the global registry.
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'fs/promises';
|
|
8
|
-
import { findRepo, unregisterRepo, listRegisteredRepos } from '../storage/repo-manager.js';
|
|
8
|
+
import { findRepo, unregisterRepo, listRegisteredRepos, assertSafeStoragePath, UnsafeStoragePathError, } from '../storage/repo-manager.js';
|
|
9
9
|
export const cleanCommand = async (options) => {
|
|
10
10
|
// --all flag: clean all indexed repos
|
|
11
11
|
if (options?.all) {
|
|
@@ -24,6 +24,24 @@ export const cleanCommand = async (options) => {
|
|
|
24
24
|
}
|
|
25
25
|
const entries = await listRegisteredRepos();
|
|
26
26
|
for (const entry of entries) {
|
|
27
|
+
// Safety guard (#1003 review — @magyargergo): same rationale as
|
|
28
|
+
// remove.ts. `~/.gitnexus/registry.json` is user-writable, so a
|
|
29
|
+
// corrupted or hand-edited entry could point storagePath at the
|
|
30
|
+
// repo root, an empty string, or anywhere else — and
|
|
31
|
+
// fs.rm(recursive: true) on any of those would be catastrophic.
|
|
32
|
+
// Skip poisoned entries without touching disk, but keep going
|
|
33
|
+
// through the rest of the registry (preserves the existing
|
|
34
|
+
// per-repo error-tolerance semantics of `clean --all`).
|
|
35
|
+
try {
|
|
36
|
+
assertSafeStoragePath(entry);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err instanceof UnsafeStoragePathError) {
|
|
40
|
+
console.error(`Refusing to clean ${entry.name}: ${err.message}`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
27
45
|
try {
|
|
28
46
|
await fs.rm(entry.storagePath, { recursive: true, force: true });
|
|
29
47
|
await unregisterRepo(entry.path);
|
package/dist/cli/index.js
CHANGED
|
@@ -59,6 +59,12 @@ program
|
|
|
59
59
|
.option('-f, --force', 'Skip confirmation prompt')
|
|
60
60
|
.option('--all', 'Clean all indexed repos')
|
|
61
61
|
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
|
|
62
|
+
program
|
|
63
|
+
.command('remove <target>')
|
|
64
|
+
.description('Delete the GitNexus index for a registered repo (by alias, name, or absolute path). ' +
|
|
65
|
+
'Unlike `clean`, does not require being inside the repo. Idempotent on unknown targets.')
|
|
66
|
+
.option('-f, --force', 'Skip confirmation prompt')
|
|
67
|
+
.action(createLazyAction(() => import('./remove.js'), 'removeCommand'));
|
|
62
68
|
program
|
|
63
69
|
.command('wiki [path]')
|
|
64
70
|
.description('Generate repository wiki from knowledge graph')
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remove Command (#664)
|
|
3
|
+
*
|
|
4
|
+
* Delete the `.gitnexus/` index for a registered repo and unregister it
|
|
5
|
+
* from the global registry (~/.gitnexus/registry.json). The target is
|
|
6
|
+
* identified by alias / basename-derived name / remote-inferred name /
|
|
7
|
+
* absolute path — no `--repo` flag, just a positional argument so the
|
|
8
|
+
* destructive-command ergonomics match `clean` (which is also
|
|
9
|
+
* destructive but scoped to `process.cwd()`).
|
|
10
|
+
*
|
|
11
|
+
* Compared to `clean`:
|
|
12
|
+
* - `clean` acts on the repo discovered by walking up from cwd.
|
|
13
|
+
* - `remove` acts on any registered repo identified by name or path.
|
|
14
|
+
*
|
|
15
|
+
* Behaviour notes:
|
|
16
|
+
* - Idempotent on unknown targets: exits 0 with a warning so that
|
|
17
|
+
* `remove X && analyze Y` keeps working in scripts. Per #664:
|
|
18
|
+
* "behave atomically and idempotently so retries are safe".
|
|
19
|
+
* - Atomic order mirrors `clean`: fs.rm FIRST, then unregister. A
|
|
20
|
+
* partial failure leaves the registry pointing at a missing dir
|
|
21
|
+
* (recoverable by `listRegisteredRepos({ validate: true })` on
|
|
22
|
+
* next read) rather than the opposite, which would orphan
|
|
23
|
+
* .gitnexus/ directories on disk.
|
|
24
|
+
* - `-f` / `--force` matches the confirmation-skip semantics of
|
|
25
|
+
* `clean -f`. (Distinct from `analyze --force`, which re-indexes;
|
|
26
|
+
* here there is no pipeline, so no conflation.)
|
|
27
|
+
*/
|
|
28
|
+
export declare const removeCommand: (target: string, options?: {
|
|
29
|
+
force?: boolean;
|
|
30
|
+
}) => Promise<void>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remove Command (#664)
|
|
3
|
+
*
|
|
4
|
+
* Delete the `.gitnexus/` index for a registered repo and unregister it
|
|
5
|
+
* from the global registry (~/.gitnexus/registry.json). The target is
|
|
6
|
+
* identified by alias / basename-derived name / remote-inferred name /
|
|
7
|
+
* absolute path — no `--repo` flag, just a positional argument so the
|
|
8
|
+
* destructive-command ergonomics match `clean` (which is also
|
|
9
|
+
* destructive but scoped to `process.cwd()`).
|
|
10
|
+
*
|
|
11
|
+
* Compared to `clean`:
|
|
12
|
+
* - `clean` acts on the repo discovered by walking up from cwd.
|
|
13
|
+
* - `remove` acts on any registered repo identified by name or path.
|
|
14
|
+
*
|
|
15
|
+
* Behaviour notes:
|
|
16
|
+
* - Idempotent on unknown targets: exits 0 with a warning so that
|
|
17
|
+
* `remove X && analyze Y` keeps working in scripts. Per #664:
|
|
18
|
+
* "behave atomically and idempotently so retries are safe".
|
|
19
|
+
* - Atomic order mirrors `clean`: fs.rm FIRST, then unregister. A
|
|
20
|
+
* partial failure leaves the registry pointing at a missing dir
|
|
21
|
+
* (recoverable by `listRegisteredRepos({ validate: true })` on
|
|
22
|
+
* next read) rather than the opposite, which would orphan
|
|
23
|
+
* .gitnexus/ directories on disk.
|
|
24
|
+
* - `-f` / `--force` matches the confirmation-skip semantics of
|
|
25
|
+
* `clean -f`. (Distinct from `analyze --force`, which re-indexes;
|
|
26
|
+
* here there is no pipeline, so no conflation.)
|
|
27
|
+
*/
|
|
28
|
+
import fs from 'fs/promises';
|
|
29
|
+
import { readRegistry, resolveRegistryEntry, assertSafeStoragePath, unregisterRepo, RegistryNotFoundError, RegistryAmbiguousTargetError, UnsafeStoragePathError, } from '../storage/repo-manager.js';
|
|
30
|
+
export const removeCommand = async (target, options) => {
|
|
31
|
+
// Read the registry snapshot once and pass it to the resolver — this
|
|
32
|
+
// lets us render the "before" state in the dry-run path without a
|
|
33
|
+
// second disk read.
|
|
34
|
+
const entries = await readRegistry();
|
|
35
|
+
let entry;
|
|
36
|
+
try {
|
|
37
|
+
entry = resolveRegistryEntry(entries, target);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (err instanceof RegistryNotFoundError) {
|
|
41
|
+
// Idempotent: missing target is a no-op warning, not an error.
|
|
42
|
+
// The `availableNames` hint comes from the error itself so users
|
|
43
|
+
// can see what they might have meant.
|
|
44
|
+
console.warn(`Nothing to remove: ${err.message}`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (err instanceof RegistryAmbiguousTargetError) {
|
|
48
|
+
// Duplicate aliases are allowed via --allow-duplicate-name (#829);
|
|
49
|
+
// refuse to guess which one the user meant — surface the full list
|
|
50
|
+
// and exit non-zero so scripts don't silently pick the wrong repo.
|
|
51
|
+
console.error(`Error: ${err.message}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
// Confirmation gate — same shape as `clean`. Default is a dry-run
|
|
57
|
+
// that describes what would be deleted; `--force` actually deletes.
|
|
58
|
+
if (!options?.force) {
|
|
59
|
+
console.log(`This will delete the GitNexus index for: ${entry.name}`);
|
|
60
|
+
console.log(` Path: ${entry.path}`);
|
|
61
|
+
console.log(` Storage: ${entry.storagePath}`);
|
|
62
|
+
console.log('\nRun with --force to confirm deletion.');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Safety guard (#1003 review — @magyargergo): refuse to proceed if
|
|
66
|
+
// the registry entry's `storagePath` isn't the canonical
|
|
67
|
+
// `<entry.path>/.gitnexus` subfolder. `~/.gitnexus/registry.json` is
|
|
68
|
+
// user-writable, so a corrupted or hand-edited entry could point
|
|
69
|
+
// storagePath at the repo root, an empty string (→ cwd), a parent
|
|
70
|
+
// dir, or anywhere else; `fs.rm(recursive: true, force: true)` on
|
|
71
|
+
// any of those would be a runtime disaster. Bail before touching
|
|
72
|
+
// disk, with an actionable hint for recovering a broken registry.
|
|
73
|
+
try {
|
|
74
|
+
assertSafeStoragePath(entry);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (err instanceof UnsafeStoragePathError) {
|
|
78
|
+
console.error(`Error: ${err.message}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
// Deletion order: fs.rm first, then unregister. If fs.rm fails mid-way,
|
|
84
|
+
// the registry entry stays so the user can retry. If fs.rm succeeds but
|
|
85
|
+
// unregister throws (e.g. ENOSPC on registry write), the entry becomes
|
|
86
|
+
// orphaned — `listRegisteredRepos({ validate: true })` prunes those on
|
|
87
|
+
// next read, so the failure is self-healing.
|
|
88
|
+
try {
|
|
89
|
+
await fs.rm(entry.storagePath, { recursive: true, force: true });
|
|
90
|
+
await unregisterRepo(entry.path);
|
|
91
|
+
console.log(`Removed: ${entry.name}`);
|
|
92
|
+
console.log(` Path: ${entry.path}`);
|
|
93
|
+
console.log(` Storage: ${entry.storagePath}`);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.error(`Failed to remove ${entry.name}:`, err);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
@@ -283,24 +283,13 @@ export async function runGroupImpact(deps, params) {
|
|
|
283
283
|
}
|
|
284
284
|
const localObj = local;
|
|
285
285
|
if (localObj?.error && typeof localObj.error === 'string') {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
summary: {
|
|
294
|
-
direct: 0,
|
|
295
|
-
processes_affected: 0,
|
|
296
|
-
modules_affected: 0,
|
|
297
|
-
cross_repo_hits: 0,
|
|
298
|
-
},
|
|
299
|
-
risk: 'UNKNOWN',
|
|
300
|
-
timeoutMs,
|
|
301
|
-
crossDepthWarning,
|
|
302
|
-
};
|
|
303
|
-
return empty;
|
|
286
|
+
// Fail closed: the local-impact phase errored (missing symbol, graph-load
|
|
287
|
+
// failure, thrown exception wrapped by safeLocalImpact, or port-returned
|
|
288
|
+
// `{ error }`). Do NOT wrap it into a zero-hit success payload — callers
|
|
289
|
+
// branch on top-level `error`, and a blast-radius tool reporting "no
|
|
290
|
+
// impact" on the failure path is a false negative on a safety-critical
|
|
291
|
+
// signal. Bubble the error so consumers treat it as a failure.
|
|
292
|
+
return { error: `Local impact failed for ${repoPath}: ${localObj.error}` };
|
|
304
293
|
}
|
|
305
294
|
if (servicePrefix) {
|
|
306
295
|
const tf = localObj?.target?.filePath;
|
|
@@ -5,6 +5,37 @@
|
|
|
5
5
|
* Also maintains a global registry at ~/.gitnexus/registry.json
|
|
6
6
|
* so the MCP server can discover indexed repos from any cwd.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* Normalise a repo path for registry comparison across platforms
|
|
10
|
+
* (#664 review feedback from @evander-wang).
|
|
11
|
+
*
|
|
12
|
+
* Why this exists: `path.resolve` alone is NOT enough for
|
|
13
|
+
* cross-platform registry stability.
|
|
14
|
+
* - **macOS**: tmpdirs and `/var` are symlinks to `/private/var`.
|
|
15
|
+
* A child process that stored `/private/var/folders/.../repo` in
|
|
16
|
+
* the registry cannot later be matched by an outer caller that
|
|
17
|
+
* supplies the symlink form `/var/folders/.../repo`. `path.resolve`
|
|
18
|
+
* does not follow symlinks; `realpathSync.native` does.
|
|
19
|
+
* - **Windows**: GitHub runners surface tmpdirs in 8.3 short-name
|
|
20
|
+
* form (`RUNNERA~1\...`), but `process.cwd()` often returns the
|
|
21
|
+
* long form (`runneradmin\...`). `realpathSync.native` normalises
|
|
22
|
+
* both sides to the long-name canonical path.
|
|
23
|
+
*
|
|
24
|
+
* Fallback behaviour: if the path does not exist on disk (e.g. a user
|
|
25
|
+
* passed `gitnexus remove some-alias` and the alias misses every
|
|
26
|
+
* registry entry, or the caller is resolving a path that was deleted
|
|
27
|
+
* after registration), we return `path.resolve(p)` rather than
|
|
28
|
+
* throwing. This preserves the idempotent-on-missing semantics of
|
|
29
|
+
* `resolveRegistryEntry` / `remove`.
|
|
30
|
+
*
|
|
31
|
+
* Backwards compatibility: this function is applied to BOTH the
|
|
32
|
+
* caller-supplied input AND each stored `entry.path` at compare time
|
|
33
|
+
* inside `resolveRegistryEntry`, so registries written by older
|
|
34
|
+
* versions (where `registerRepo` only ran `path.resolve`) still match
|
|
35
|
+
* correctly. Newly-written entries are canonicalised at write time too
|
|
36
|
+
* so the registry stabilises over analyze/re-analyze cycles.
|
|
37
|
+
*/
|
|
38
|
+
export declare const canonicalizePath: (p: string) => string;
|
|
8
39
|
export interface RepoMeta {
|
|
9
40
|
repoPath: string;
|
|
10
41
|
lastCommit: string;
|
|
@@ -181,6 +212,108 @@ export declare const registerRepo: (repoPath: string, meta: RepoMeta, opts?: Reg
|
|
|
181
212
|
* Called after `gitnexus clean`.
|
|
182
213
|
*/
|
|
183
214
|
export declare const unregisterRepo: (repoPath: string) => Promise<void>;
|
|
215
|
+
/**
|
|
216
|
+
* Thrown by {@link resolveRegistryEntry} when no registered repo matches
|
|
217
|
+
* the caller's target string (by alias, basename, remote-inferred name,
|
|
218
|
+
* or resolved path). CLI callers that want idempotent "remove" semantics
|
|
219
|
+
* should catch this and exit 0 with a warning; non-idempotent callers
|
|
220
|
+
* (e.g. MCP tools) can surface the error directly.
|
|
221
|
+
*/
|
|
222
|
+
export declare class RegistryNotFoundError extends Error {
|
|
223
|
+
readonly target: string;
|
|
224
|
+
readonly availableNames: string[];
|
|
225
|
+
readonly kind: "RegistryNotFoundError";
|
|
226
|
+
constructor(target: string, availableNames: string[]);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Thrown by {@link resolveRegistryEntry} when the target string matches
|
|
230
|
+
* the `name` of two or more entries — only possible when the user
|
|
231
|
+
* previously registered duplicates via `analyze --name X
|
|
232
|
+
* --allow-duplicate-name` (#829). The error carries enough information
|
|
233
|
+
* for the caller to render an actionable disambiguation hint without
|
|
234
|
+
* string-matching on `.message`.
|
|
235
|
+
*
|
|
236
|
+
* `kind` is a string literal discriminant (same pattern as
|
|
237
|
+
* {@link RegistryNameCollisionError}) so callers can narrow via
|
|
238
|
+
* `err.kind === 'RegistryAmbiguousTargetError'` without importing the
|
|
239
|
+
* class.
|
|
240
|
+
*/
|
|
241
|
+
export declare class RegistryAmbiguousTargetError extends Error {
|
|
242
|
+
readonly target: string;
|
|
243
|
+
readonly matches: RegistryEntry[];
|
|
244
|
+
readonly kind: "RegistryAmbiguousTargetError";
|
|
245
|
+
constructor(target: string, matches: RegistryEntry[]);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Thrown by {@link assertSafeStoragePath} when a registry entry's
|
|
249
|
+
* `storagePath` does NOT point at the expected `<entry.path>/.gitnexus`
|
|
250
|
+
* subfolder. CLI destructive commands (`remove`, `clean --all`) should
|
|
251
|
+
* catch this and exit non-zero without deleting anything — the usual
|
|
252
|
+
* cause is a corrupted or hand-edited `~/.gitnexus/registry.json`, and
|
|
253
|
+
* proceeding would mean `fs.rm(recursive: true)` on whatever odd path
|
|
254
|
+
* the entry is pointing at.
|
|
255
|
+
*/
|
|
256
|
+
export declare class UnsafeStoragePathError extends Error {
|
|
257
|
+
readonly entry: RegistryEntry;
|
|
258
|
+
readonly expectedStoragePath: string;
|
|
259
|
+
readonly actualStoragePath: string;
|
|
260
|
+
readonly kind: "UnsafeStoragePathError";
|
|
261
|
+
constructor(entry: RegistryEntry, expectedStoragePath: string, actualStoragePath: string);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Guard rail for destructive CLI paths (`remove` #664,
|
|
265
|
+
* `clean --all` #258, future MCP `remove` tool): verify that a
|
|
266
|
+
* registry entry's `storagePath` is the canonical `<repo>/.gitnexus`
|
|
267
|
+
* subfolder of its `path`. If not, throw {@link UnsafeStoragePathError}
|
|
268
|
+
* so the caller exits without touching disk.
|
|
269
|
+
*
|
|
270
|
+
* Why this exists (#1003 review — @magyargergo):
|
|
271
|
+
* - `~/.gitnexus/registry.json` is a plain-text user-writable file.
|
|
272
|
+
* A corrupted, hand-edited, or downgrade/upgrade-racing entry
|
|
273
|
+
* could plausibly end up with `storagePath === ""` (resolves to
|
|
274
|
+
* cwd), `storagePath === path` (the repo root!), `storagePath`
|
|
275
|
+
* equal to a parent/sibling of the repo, or simply any arbitrary
|
|
276
|
+
* filesystem path.
|
|
277
|
+
* - `fs.rm(recursive: true, force: true)` on ANY of those would be
|
|
278
|
+
* a runtime disaster — at best delete the user's working tree, at
|
|
279
|
+
* worst nuke an unrelated directory tree they happen to own.
|
|
280
|
+
* - `clean` (default, cwd-scoped) is safe by construction — it
|
|
281
|
+
* re-derives storagePath from `findRepo(cwd)` and never trusts
|
|
282
|
+
* the registry field. But `clean --all` DOES iterate the registry
|
|
283
|
+
* and trust each entry's stored storagePath (same shape as
|
|
284
|
+
* `remove`), so this helper must be wired into that loop too.
|
|
285
|
+
* - `server/api.ts` recomputes storagePath from `getStoragePath(entry.path)`
|
|
286
|
+
* and so is likewise safe-by-construction.
|
|
287
|
+
*
|
|
288
|
+
* Pure string check — does NOT require the paths to exist on disk.
|
|
289
|
+
* Windows: case-insensitive; POSIX: case-sensitive. Matches the
|
|
290
|
+
* comparison shape used elsewhere in this module.
|
|
291
|
+
*/
|
|
292
|
+
export declare const assertSafeStoragePath: (entry: RegistryEntry) => void;
|
|
293
|
+
/**
|
|
294
|
+
* Resolve a user-supplied target string (from `gitnexus remove <target>`
|
|
295
|
+
* or equivalent MCP tool argument) to a single registry entry.
|
|
296
|
+
*
|
|
297
|
+
* Match precedence (first hit wins, subsequent tiers are only tried if
|
|
298
|
+
* the prior tier produces zero matches):
|
|
299
|
+
* 1. Exact resolved-path match (Windows: case-insensitive).
|
|
300
|
+
* Paths are unique by registry construction, so a path match can
|
|
301
|
+
* never be ambiguous.
|
|
302
|
+
* 2. Exact `name` match (case-insensitive). If ≥ 2 entries share the
|
|
303
|
+
* name — only possible via `--allow-duplicate-name` (#829) —
|
|
304
|
+
* throws {@link RegistryAmbiguousTargetError}.
|
|
305
|
+
*
|
|
306
|
+
* No fuzzy / partial matching — unambiguous, scriptable behaviour is
|
|
307
|
+
* more important than convenience for destructive commands.
|
|
308
|
+
*
|
|
309
|
+
* Throws {@link RegistryNotFoundError} if no entry matches.
|
|
310
|
+
*
|
|
311
|
+
* `entries` is passed in (rather than re-read) so callers that already
|
|
312
|
+
* hold the registry snapshot (e.g. to print a "before" state) can avoid
|
|
313
|
+
* a second disk read, and so tests can inject fixtures without touching
|
|
314
|
+
* `GITNEXUS_HOME`.
|
|
315
|
+
*/
|
|
316
|
+
export declare const resolveRegistryEntry: (entries: RegistryEntry[], target: string) => RegistryEntry;
|
|
184
317
|
/**
|
|
185
318
|
* List all registered repos from the global registry.
|
|
186
319
|
* Optionally validates that each entry's .gitnexus/ still exists.
|
|
@@ -6,9 +6,49 @@
|
|
|
6
6
|
* so the MCP server can discover indexed repos from any cwd.
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
|
+
import { realpathSync } from 'fs';
|
|
9
10
|
import path from 'path';
|
|
10
11
|
import os from 'os';
|
|
11
12
|
import { getInferredRepoName } from './git.js';
|
|
13
|
+
/**
|
|
14
|
+
* Normalise a repo path for registry comparison across platforms
|
|
15
|
+
* (#664 review feedback from @evander-wang).
|
|
16
|
+
*
|
|
17
|
+
* Why this exists: `path.resolve` alone is NOT enough for
|
|
18
|
+
* cross-platform registry stability.
|
|
19
|
+
* - **macOS**: tmpdirs and `/var` are symlinks to `/private/var`.
|
|
20
|
+
* A child process that stored `/private/var/folders/.../repo` in
|
|
21
|
+
* the registry cannot later be matched by an outer caller that
|
|
22
|
+
* supplies the symlink form `/var/folders/.../repo`. `path.resolve`
|
|
23
|
+
* does not follow symlinks; `realpathSync.native` does.
|
|
24
|
+
* - **Windows**: GitHub runners surface tmpdirs in 8.3 short-name
|
|
25
|
+
* form (`RUNNERA~1\...`), but `process.cwd()` often returns the
|
|
26
|
+
* long form (`runneradmin\...`). `realpathSync.native` normalises
|
|
27
|
+
* both sides to the long-name canonical path.
|
|
28
|
+
*
|
|
29
|
+
* Fallback behaviour: if the path does not exist on disk (e.g. a user
|
|
30
|
+
* passed `gitnexus remove some-alias` and the alias misses every
|
|
31
|
+
* registry entry, or the caller is resolving a path that was deleted
|
|
32
|
+
* after registration), we return `path.resolve(p)` rather than
|
|
33
|
+
* throwing. This preserves the idempotent-on-missing semantics of
|
|
34
|
+
* `resolveRegistryEntry` / `remove`.
|
|
35
|
+
*
|
|
36
|
+
* Backwards compatibility: this function is applied to BOTH the
|
|
37
|
+
* caller-supplied input AND each stored `entry.path` at compare time
|
|
38
|
+
* inside `resolveRegistryEntry`, so registries written by older
|
|
39
|
+
* versions (where `registerRepo` only ran `path.resolve`) still match
|
|
40
|
+
* correctly. Newly-written entries are canonicalised at write time too
|
|
41
|
+
* so the registry stabilises over analyze/re-analyze cycles.
|
|
42
|
+
*/
|
|
43
|
+
export const canonicalizePath = (p) => {
|
|
44
|
+
const resolved = path.resolve(p);
|
|
45
|
+
try {
|
|
46
|
+
return realpathSync.native(resolved);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return resolved;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
12
52
|
const GITNEXUS_DIR = '.gitnexus';
|
|
13
53
|
// ─── Local Storage Helpers ─────────────────────────────────────────────
|
|
14
54
|
/**
|
|
@@ -265,12 +305,31 @@ const hasCustomAlias = (entry, inferredName) => {
|
|
|
265
305
|
* MCP-visible repo name (#979).
|
|
266
306
|
*/
|
|
267
307
|
export const registerRepo = async (repoPath, meta, opts) => {
|
|
308
|
+
// Preserve the caller's chosen path form in the registry — don't
|
|
309
|
+
// canonicalise at write time. This matters for two reasons:
|
|
310
|
+
// 1. `list` and error messages show the path the user actually
|
|
311
|
+
// knows (e.g. the 8.3 short form they typed), not a runtime-
|
|
312
|
+
// resolved long form they've never seen.
|
|
313
|
+
// 2. Keeps pre-existing #829 test assertions that compare
|
|
314
|
+
// `err.existingPath` against `path.resolve(tmpPath)` stable.
|
|
315
|
+
// Canonicalisation is applied at COMPARE points only (see below),
|
|
316
|
+
// which is where the cross-platform divergence actually matters.
|
|
268
317
|
const resolved = path.resolve(repoPath);
|
|
269
318
|
const { storagePath } = getStoragePaths(resolved);
|
|
319
|
+
// Canonical form used strictly for comparison — `realpathSync.native`
|
|
320
|
+
// expands macOS /var → /private/var and Windows 8.3 → long-name,
|
|
321
|
+
// falling back to `path.resolve` when the path doesn't exist.
|
|
322
|
+
const canonicalInput = canonicalizePath(repoPath);
|
|
270
323
|
const entries = await readRegistry();
|
|
271
324
|
const existingIdx = entries.findIndex((e) => {
|
|
272
|
-
|
|
273
|
-
|
|
325
|
+
// Canonicalise the STORED entry too so pre-canonicalisation
|
|
326
|
+
// registries (written by older versions, or paths passed in a
|
|
327
|
+
// different form) still match correctly. `canonicalizePath` falls
|
|
328
|
+
// back to `path.resolve` when the path no longer exists on disk,
|
|
329
|
+
// so stale entries that have been rm'd externally still resolve
|
|
330
|
+
// to a stable key instead of throwing.
|
|
331
|
+
const a = canonicalizePath(e.path);
|
|
332
|
+
const b = canonicalInput;
|
|
274
333
|
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
275
334
|
});
|
|
276
335
|
const existing = existingIdx >= 0 ? entries[existingIdx] : null;
|
|
@@ -304,9 +363,12 @@ export const registerRepo = async (repoPath, meta, opts) => {
|
|
|
304
363
|
// messages and list output #829 ships).
|
|
305
364
|
const explicitName = opts?.name !== undefined || isPreservedAlias;
|
|
306
365
|
if (explicitName && !opts?.allowDuplicateName) {
|
|
366
|
+
// Compare canonical-vs-canonical here too so `/var/foo` and
|
|
367
|
+
// `/private/var/foo` (same repo, different form) aren't treated as
|
|
368
|
+
// two colliding paths.
|
|
307
369
|
const collidingEntry = entries.find((e, i) => i !== existingIdx &&
|
|
308
370
|
e.name.toLowerCase() === name.toLowerCase() &&
|
|
309
|
-
|
|
371
|
+
canonicalizePath(e.path) !== canonicalInput);
|
|
310
372
|
if (collidingEntry) {
|
|
311
373
|
throw new RegistryNameCollisionError(name, collidingEntry.path, resolved);
|
|
312
374
|
}
|
|
@@ -333,11 +395,193 @@ export const registerRepo = async (repoPath, meta, opts) => {
|
|
|
333
395
|
* Called after `gitnexus clean`.
|
|
334
396
|
*/
|
|
335
397
|
export const unregisterRepo = async (repoPath) => {
|
|
336
|
-
|
|
398
|
+
// Canonicalise BOTH sides so an unregister call issued with the
|
|
399
|
+
// symlink form (`/var/folders/.../repo`) still matches an entry
|
|
400
|
+
// written with the realpath form (`/private/var/folders/.../repo`),
|
|
401
|
+
// and vice versa. Matches the semantics of `registerRepo` and
|
|
402
|
+
// `resolveRegistryEntry` post-#1003 review.
|
|
403
|
+
const resolved = canonicalizePath(repoPath);
|
|
337
404
|
const entries = await readRegistry();
|
|
338
|
-
const
|
|
405
|
+
const matches = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
406
|
+
const filtered = entries.filter((e) => !matches(canonicalizePath(e.path), resolved));
|
|
339
407
|
await writeRegistry(filtered);
|
|
340
408
|
};
|
|
409
|
+
/**
|
|
410
|
+
* Thrown by {@link resolveRegistryEntry} when no registered repo matches
|
|
411
|
+
* the caller's target string (by alias, basename, remote-inferred name,
|
|
412
|
+
* or resolved path). CLI callers that want idempotent "remove" semantics
|
|
413
|
+
* should catch this and exit 0 with a warning; non-idempotent callers
|
|
414
|
+
* (e.g. MCP tools) can surface the error directly.
|
|
415
|
+
*/
|
|
416
|
+
export class RegistryNotFoundError extends Error {
|
|
417
|
+
target;
|
|
418
|
+
availableNames;
|
|
419
|
+
kind = 'RegistryNotFoundError';
|
|
420
|
+
constructor(target, availableNames) {
|
|
421
|
+
const hint = availableNames.length > 0
|
|
422
|
+
? ` Available: ${availableNames.join(', ')}.`
|
|
423
|
+
: ' No repositories are currently registered.';
|
|
424
|
+
super(`No registered repo matches "${target}".${hint}`);
|
|
425
|
+
this.target = target;
|
|
426
|
+
this.availableNames = availableNames;
|
|
427
|
+
this.name = 'RegistryNotFoundError';
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Thrown by {@link resolveRegistryEntry} when the target string matches
|
|
432
|
+
* the `name` of two or more entries — only possible when the user
|
|
433
|
+
* previously registered duplicates via `analyze --name X
|
|
434
|
+
* --allow-duplicate-name` (#829). The error carries enough information
|
|
435
|
+
* for the caller to render an actionable disambiguation hint without
|
|
436
|
+
* string-matching on `.message`.
|
|
437
|
+
*
|
|
438
|
+
* `kind` is a string literal discriminant (same pattern as
|
|
439
|
+
* {@link RegistryNameCollisionError}) so callers can narrow via
|
|
440
|
+
* `err.kind === 'RegistryAmbiguousTargetError'` without importing the
|
|
441
|
+
* class.
|
|
442
|
+
*/
|
|
443
|
+
export class RegistryAmbiguousTargetError extends Error {
|
|
444
|
+
target;
|
|
445
|
+
matches;
|
|
446
|
+
kind = 'RegistryAmbiguousTargetError';
|
|
447
|
+
constructor(target, matches) {
|
|
448
|
+
const listing = matches.map((m) => ` - ${m.name} (${m.path})`).join('\n');
|
|
449
|
+
super(`Multiple registered repos match "${target}":\n${listing}\n` +
|
|
450
|
+
`Pass the absolute path instead to disambiguate.`);
|
|
451
|
+
this.target = target;
|
|
452
|
+
this.matches = matches;
|
|
453
|
+
this.name = 'RegistryAmbiguousTargetError';
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Thrown by {@link assertSafeStoragePath} when a registry entry's
|
|
458
|
+
* `storagePath` does NOT point at the expected `<entry.path>/.gitnexus`
|
|
459
|
+
* subfolder. CLI destructive commands (`remove`, `clean --all`) should
|
|
460
|
+
* catch this and exit non-zero without deleting anything — the usual
|
|
461
|
+
* cause is a corrupted or hand-edited `~/.gitnexus/registry.json`, and
|
|
462
|
+
* proceeding would mean `fs.rm(recursive: true)` on whatever odd path
|
|
463
|
+
* the entry is pointing at.
|
|
464
|
+
*/
|
|
465
|
+
export class UnsafeStoragePathError extends Error {
|
|
466
|
+
entry;
|
|
467
|
+
expectedStoragePath;
|
|
468
|
+
actualStoragePath;
|
|
469
|
+
kind = 'UnsafeStoragePathError';
|
|
470
|
+
constructor(entry, expectedStoragePath, actualStoragePath) {
|
|
471
|
+
super(`Refusing to remove storage path for safety: expected ` +
|
|
472
|
+
`"${expectedStoragePath}" under the repo's .gitnexus subfolder, ` +
|
|
473
|
+
`but the registry entry has "${actualStoragePath}". ` +
|
|
474
|
+
`This usually means the registry entry is corrupted or was ` +
|
|
475
|
+
`hand-edited. Delete the entry manually from ~/.gitnexus/registry.json ` +
|
|
476
|
+
`and re-run analyze.`);
|
|
477
|
+
this.entry = entry;
|
|
478
|
+
this.expectedStoragePath = expectedStoragePath;
|
|
479
|
+
this.actualStoragePath = actualStoragePath;
|
|
480
|
+
this.name = 'UnsafeStoragePathError';
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Guard rail for destructive CLI paths (`remove` #664,
|
|
485
|
+
* `clean --all` #258, future MCP `remove` tool): verify that a
|
|
486
|
+
* registry entry's `storagePath` is the canonical `<repo>/.gitnexus`
|
|
487
|
+
* subfolder of its `path`. If not, throw {@link UnsafeStoragePathError}
|
|
488
|
+
* so the caller exits without touching disk.
|
|
489
|
+
*
|
|
490
|
+
* Why this exists (#1003 review — @magyargergo):
|
|
491
|
+
* - `~/.gitnexus/registry.json` is a plain-text user-writable file.
|
|
492
|
+
* A corrupted, hand-edited, or downgrade/upgrade-racing entry
|
|
493
|
+
* could plausibly end up with `storagePath === ""` (resolves to
|
|
494
|
+
* cwd), `storagePath === path` (the repo root!), `storagePath`
|
|
495
|
+
* equal to a parent/sibling of the repo, or simply any arbitrary
|
|
496
|
+
* filesystem path.
|
|
497
|
+
* - `fs.rm(recursive: true, force: true)` on ANY of those would be
|
|
498
|
+
* a runtime disaster — at best delete the user's working tree, at
|
|
499
|
+
* worst nuke an unrelated directory tree they happen to own.
|
|
500
|
+
* - `clean` (default, cwd-scoped) is safe by construction — it
|
|
501
|
+
* re-derives storagePath from `findRepo(cwd)` and never trusts
|
|
502
|
+
* the registry field. But `clean --all` DOES iterate the registry
|
|
503
|
+
* and trust each entry's stored storagePath (same shape as
|
|
504
|
+
* `remove`), so this helper must be wired into that loop too.
|
|
505
|
+
* - `server/api.ts` recomputes storagePath from `getStoragePath(entry.path)`
|
|
506
|
+
* and so is likewise safe-by-construction.
|
|
507
|
+
*
|
|
508
|
+
* Pure string check — does NOT require the paths to exist on disk.
|
|
509
|
+
* Windows: case-insensitive; POSIX: case-sensitive. Matches the
|
|
510
|
+
* comparison shape used elsewhere in this module.
|
|
511
|
+
*/
|
|
512
|
+
export const assertSafeStoragePath = (entry) => {
|
|
513
|
+
const expected = path.join(path.resolve(entry.path), '.gitnexus');
|
|
514
|
+
const actual = path.resolve(entry.storagePath);
|
|
515
|
+
const matches = process.platform === 'win32'
|
|
516
|
+
? expected.toLowerCase() === actual.toLowerCase()
|
|
517
|
+
: expected === actual;
|
|
518
|
+
if (!matches) {
|
|
519
|
+
throw new UnsafeStoragePathError(entry, expected, actual);
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* Resolve a user-supplied target string (from `gitnexus remove <target>`
|
|
524
|
+
* or equivalent MCP tool argument) to a single registry entry.
|
|
525
|
+
*
|
|
526
|
+
* Match precedence (first hit wins, subsequent tiers are only tried if
|
|
527
|
+
* the prior tier produces zero matches):
|
|
528
|
+
* 1. Exact resolved-path match (Windows: case-insensitive).
|
|
529
|
+
* Paths are unique by registry construction, so a path match can
|
|
530
|
+
* never be ambiguous.
|
|
531
|
+
* 2. Exact `name` match (case-insensitive). If ≥ 2 entries share the
|
|
532
|
+
* name — only possible via `--allow-duplicate-name` (#829) —
|
|
533
|
+
* throws {@link RegistryAmbiguousTargetError}.
|
|
534
|
+
*
|
|
535
|
+
* No fuzzy / partial matching — unambiguous, scriptable behaviour is
|
|
536
|
+
* more important than convenience for destructive commands.
|
|
537
|
+
*
|
|
538
|
+
* Throws {@link RegistryNotFoundError} if no entry matches.
|
|
539
|
+
*
|
|
540
|
+
* `entries` is passed in (rather than re-read) so callers that already
|
|
541
|
+
* hold the registry snapshot (e.g. to print a "before" state) can avoid
|
|
542
|
+
* a second disk read, and so tests can inject fixtures without touching
|
|
543
|
+
* `GITNEXUS_HOME`.
|
|
544
|
+
*/
|
|
545
|
+
export const resolveRegistryEntry = (entries, target) => {
|
|
546
|
+
// Tier 1: path match. Canonicalise BOTH sides so symlink and
|
|
547
|
+
// Windows-8.3 quirks don't cause a false miss — e.g. the caller
|
|
548
|
+
// passes `/var/folders/.../repo` while the registry has
|
|
549
|
+
// `/private/var/folders/.../repo` (both resolve to the same
|
|
550
|
+
// `realpath.native`). See `canonicalizePath` for the rationale.
|
|
551
|
+
//
|
|
552
|
+
// Canonicalising the STORED entry (not just the input) is what gives
|
|
553
|
+
// us backward-compat for registries written by versions that only
|
|
554
|
+
// ran `path.resolve` — both get canonicalised here at compare time.
|
|
555
|
+
const canonicalTarget = canonicalizePath(target);
|
|
556
|
+
const pathMatch = entries.find((e) => {
|
|
557
|
+
const a = canonicalizePath(e.path);
|
|
558
|
+
const b = canonicalTarget;
|
|
559
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
560
|
+
});
|
|
561
|
+
if (pathMatch)
|
|
562
|
+
return pathMatch;
|
|
563
|
+
// Tier 2: name match. Case-insensitive on all platforms — registry
|
|
564
|
+
// name collisions are already filtered case-insensitively in
|
|
565
|
+
// `registerRepo`, so "APP" vs "app" are considered the same key.
|
|
566
|
+
const targetLower = target.toLowerCase();
|
|
567
|
+
const nameMatches = entries.filter((e) => e.name.toLowerCase() === targetLower);
|
|
568
|
+
if (nameMatches.length === 1)
|
|
569
|
+
return nameMatches[0];
|
|
570
|
+
if (nameMatches.length > 1) {
|
|
571
|
+
throw new RegistryAmbiguousTargetError(target, nameMatches);
|
|
572
|
+
}
|
|
573
|
+
// Tier 3: miss. Build the available-names hint ONCE; resolveRepo-style
|
|
574
|
+
// disambiguated labels (`app (/path)`) are applied when the same name
|
|
575
|
+
// appears in multiple entries so the user sees the same hint shape as
|
|
576
|
+
// `-r <name>` errors.
|
|
577
|
+
const nameCounts = new Map();
|
|
578
|
+
for (const e of entries) {
|
|
579
|
+
const key = e.name.toLowerCase();
|
|
580
|
+
nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1);
|
|
581
|
+
}
|
|
582
|
+
const availableNames = entries.map((e) => (nameCounts.get(e.name.toLowerCase()) ?? 0) > 1 ? `${e.name} (${e.path})` : e.name);
|
|
583
|
+
throw new RegistryNotFoundError(target, availableNames);
|
|
584
|
+
};
|
|
341
585
|
/**
|
|
342
586
|
* List all registered repos from the global registry.
|
|
343
587
|
* Optionally validates that each entry's .gitnexus/ still exists.
|
package/package.json
CHANGED