gitnexus 1.6.3-rc.17 → 1.6.3-rc.19

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.
@@ -18,5 +18,20 @@ export interface AnalyzeOptions {
18
18
  noStats?: boolean;
19
19
  /** Index the folder even when no .git directory is present. */
20
20
  skipGit?: boolean;
21
+ /**
22
+ * Override the default basename-derived registry `name` with a
23
+ * user-supplied alias (#829). Disambiguates repos whose paths share a
24
+ * basename. Persisted — subsequent re-analyses of the same path without
25
+ * `--name` preserve the alias.
26
+ */
27
+ name?: string;
28
+ /**
29
+ * Allow registration even when another path already uses the same
30
+ * `--name` alias (#829). Intentionally a distinct flag from `--force`
31
+ * because the user may want to coexist under the same name WITHOUT
32
+ * paying the cost of a pipeline re-index. Maps to registerRepo's
33
+ * `allowDuplicateName` option end-to-end.
34
+ */
35
+ allowDuplicateName?: boolean;
21
36
  }
22
37
  export declare const analyzeCommand: (inputPath?: string, options?: AnalyzeOptions) => Promise<void>;
@@ -12,7 +12,7 @@ import { execFileSync } from 'child_process';
12
12
  import v8 from 'v8';
13
13
  import cliProgress from 'cli-progress';
14
14
  import { closeLbug } from '../core/lbug/lbug-adapter.js';
15
- import { getStoragePaths, getGlobalRegistryPath } from '../storage/repo-manager.js';
15
+ import { getStoragePaths, getGlobalRegistryPath, RegistryNameCollisionError, } from '../storage/repo-manager.js';
16
16
  import { getGitRoot, hasGitDir } from '../storage/git.js';
17
17
  import { runFullAnalysis } from '../core/run-analyze.js';
18
18
  import fs from 'fs/promises';
@@ -146,11 +146,20 @@ export const analyzeCommand = async (inputPath, options) => {
146
146
  // ── Run shared analysis orchestrator ───────────────────────────────
147
147
  try {
148
148
  const result = await runFullAnalysis(repoPath, {
149
+ // Pipeline re-index — OR'd with --skills because skill generation
150
+ // needs a fresh pipelineResult. Has no bearing on the registry
151
+ // collision guard (see allowDuplicateName below).
149
152
  force: options?.force || options?.skills,
150
153
  embeddings: options?.embeddings,
151
154
  skipGit: options?.skipGit,
152
155
  skipAgentsMd: options?.skipAgentsMd,
153
156
  noStats: options?.noStats,
157
+ registryName: options?.name,
158
+ // Registry-collision bypass — its own CLI flag, intentionally NOT
159
+ // overloading --force. A user who hits the collision guard should
160
+ // be able to accept the duplicate name without also paying the
161
+ // cost of a full pipeline re-index. See #829 review round 2.
162
+ allowDuplicateName: options?.allowDuplicateName,
154
163
  }, {
155
164
  onProgress: (_phase, percent, message) => {
156
165
  updateBar(percent, message);
@@ -234,6 +243,18 @@ export const analyzeCommand = async (inputPath, options) => {
234
243
  console.error = origError;
235
244
  bar.stop();
236
245
  const msg = err.message || String(err);
246
+ // Registry name-collision from --name (#829) — surface as an
247
+ // actionable error rather than a generic stack-trace.
248
+ if (err instanceof RegistryNameCollisionError) {
249
+ console.error(`\n Registry name collision:\n`);
250
+ console.error(` "${err.registryName}" is already used by "${err.existingPath}".\n`);
251
+ console.error(` Options:`);
252
+ console.error(` • Pick a different alias: gitnexus analyze --name <alias>`);
253
+ console.error(` • Allow the duplicate: gitnexus analyze --allow-duplicate-name (leaves "-r ${err.registryName}" ambiguous)`);
254
+ console.error('');
255
+ process.exitCode = 1;
256
+ return;
257
+ }
237
258
  console.error(`\n Analysis failed: ${msg}\n`);
238
259
  // Provide helpful guidance for known failure modes
239
260
  if (msg.includes('Maximum call stack size exceeded') ||
package/dist/cli/index.js CHANGED
@@ -22,6 +22,10 @@ program
22
22
  .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
23
23
  .option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
24
24
  .option('--skip-git', 'Index a folder without requiring a .git directory')
25
+ .option('--name <alias>', 'Register this repo under a custom name in ~/.gitnexus/registry.json ' +
26
+ '(disambiguates repos whose paths share a basename, e.g. two different .../app folders)')
27
+ .option('--allow-duplicate-name', 'Register this repo even if another path already uses the same --name alias. ' +
28
+ 'Leaves `-r <name>` ambiguous for the two paths; use -r <path> to disambiguate.')
25
29
  .option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
26
30
  .addHelpText('after', '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)')
27
31
  .action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
package/dist/cli/list.js CHANGED
@@ -12,11 +12,21 @@ export const listCommand = async () => {
12
12
  return;
13
13
  }
14
14
  console.log(`\n Indexed Repositories (${entries.length})\n`);
15
+ // Count occurrences of each name so colliding entries can be
16
+ // disambiguated in the header (#829). Unique-name entries render
17
+ // identically to pre-#829 output; only collisions gain a suffix.
18
+ const nameCounts = new Map();
19
+ for (const e of entries) {
20
+ const key = e.name.toLowerCase();
21
+ nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1);
22
+ }
15
23
  for (const entry of entries) {
16
24
  const indexedDate = new Date(entry.indexedAt).toLocaleString();
17
25
  const stats = entry.stats || {};
18
26
  const commitShort = entry.lastCommit?.slice(0, 7) || 'unknown';
19
- console.log(` ${entry.name}`);
27
+ const hasCollision = (nameCounts.get(entry.name.toLowerCase()) ?? 0) > 1;
28
+ const header = hasCollision ? `${entry.name} (${entry.path})` : entry.name;
29
+ console.log(` ${header}`);
20
30
  console.log(` Path: ${entry.path}`);
21
31
  console.log(` Indexed: ${indexedDate}`);
22
32
  console.log(` Commit: ${commitShort}`);
@@ -13,6 +13,12 @@ export interface AnalyzeCallbacks {
13
13
  onLog?: (message: string) => void;
14
14
  }
15
15
  export interface AnalyzeOptions {
16
+ /**
17
+ * Force a full re-index of the pipeline. Callers may OR this with
18
+ * other flags that imply re-analysis (e.g. `--skills`), so the value
19
+ * here is the PIPELINE-force signal, NOT the registry-collision
20
+ * bypass. See `allowDuplicateName` below.
21
+ */
16
22
  force?: boolean;
17
23
  embeddings?: boolean;
18
24
  skipGit?: boolean;
@@ -20,6 +26,21 @@ export interface AnalyzeOptions {
20
26
  skipAgentsMd?: boolean;
21
27
  /** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
22
28
  noStats?: boolean;
29
+ /**
30
+ * User-provided alias for the registry `name` (#829). When set,
31
+ * forwarded to `registerRepo` so the indexed repo is stored under
32
+ * this alias instead of the path-derived basename.
33
+ */
34
+ registryName?: string;
35
+ /**
36
+ * Bypass the `RegistryNameCollisionError` guard and allow two paths
37
+ * to register under the same `name` (#829). Controlled by the
38
+ * dedicated `--allow-duplicate-name` CLI flag, intentionally
39
+ * independent from `--force` — users who hit the collision guard
40
+ * should be able to accept the duplicate without paying the cost
41
+ * of a pipeline re-index.
42
+ */
43
+ allowDuplicateName?: boolean;
23
44
  }
24
45
  export interface AnalyzeResult {
25
46
  repoName: string;
@@ -218,7 +218,15 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
218
218
  },
219
219
  };
220
220
  await saveMeta(storagePath, meta);
221
- await registerRepo(repoPath, meta);
221
+ // Forward the --name alias and the registry-collision bypass bit.
222
+ // `allowDuplicateName` is its own concern — independent from the
223
+ // pipeline `force` above. The CLI maps it from
224
+ // `--allow-duplicate-name` only; `--force` and `--skills` both
225
+ // trigger pipeline re-run but never bypass the registry guard.
226
+ await registerRepo(repoPath, meta, {
227
+ name: options.registryName,
228
+ allowDuplicateName: options.allowDuplicateName,
229
+ });
222
230
  // Only attempt to update .gitignore when a .git directory is present.
223
231
  if (hasGitDir(repoPath)) {
224
232
  await addToGitignore(repoPath);
@@ -279,12 +279,20 @@ export class LocalBackend {
279
279
  if (this.repos.size === 0) {
280
280
  throw new Error('No indexed repositories. Run: gitnexus analyze');
281
281
  }
282
+ // Build a disambiguated "Available: …" list (#829). When two handles
283
+ // share a name, annotate each colliding label with its path so the
284
+ // caller can actually pick the right one. Single-name entries render
285
+ // identically to pre-#829 output.
286
+ const nameCounts = new Map();
287
+ for (const h of this.repos.values()) {
288
+ const key = h.name.toLowerCase();
289
+ nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1);
290
+ }
291
+ const labels = [...this.repos.values()].map((h) => (nameCounts.get(h.name.toLowerCase()) ?? 0) > 1 ? `${h.name} (${h.repoPath})` : h.name);
282
292
  if (repoParam) {
283
- const names = [...this.repos.values()].map((h) => h.name);
284
- throw new Error(`Repository "${repoParam}" not found. Available: ${names.join(', ')}`);
293
+ throw new Error(`Repository "${repoParam}" not found. Available: ${labels.join(', ')}`);
285
294
  }
286
- const names = [...this.repos.values()].map((h) => h.name);
287
- throw new Error(`Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${names.join(', ')}`);
295
+ throw new Error(`Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${labels.join(', ')}`);
288
296
  }
289
297
  /**
290
298
  * Try to resolve a repo from the in-memory cache. Returns null on miss.
@@ -102,11 +102,72 @@ export declare const getGlobalRegistryPath: () => string;
102
102
  * Read the global registry. Returns empty array if not found.
103
103
  */
104
104
  export declare const readRegistry: () => Promise<RegistryEntry[]>;
105
+ /**
106
+ * Options for {@link registerRepo}. All optional — callers without any
107
+ * disambiguation requirement can keep calling `registerRepo(path, meta)`
108
+ * unchanged.
109
+ */
110
+ export interface RegisterRepoOptions {
111
+ /**
112
+ * User-provided alias from `analyze --name <alias>` (#829). Overrides
113
+ * the default basename-derived registry `name`. Persisted — subsequent
114
+ * re-analyses of the same path without `--name` preserve the alias.
115
+ */
116
+ name?: string;
117
+ /**
118
+ * Allow two DIFFERENT repo paths to register under the same alias
119
+ * (#829). Mapped from the `--allow-duplicate-name` CLI flag.
120
+ *
121
+ * Scope: this flag governs cross-path alias sharing only — one repo
122
+ * path always has exactly one registry entry (and therefore exactly
123
+ * one alias). Re-analyzing the same path with `--name Y` overwrites
124
+ * a previous `--name X`; it does NOT create a second entry or a
125
+ * second alias for the same path (see the upsert-by-resolved-path
126
+ * logic in {@link registerRepo} and the
127
+ * `re-registerRepo with a different name overrides the previous
128
+ * alias` test in `test/unit/repo-manager.test.ts`).
129
+ *
130
+ * Distinct from `--force` (which only triggers pipeline re-index);
131
+ * a user accepting a duplicate alias should not be forced to also
132
+ * re-run the full pipeline.
133
+ */
134
+ allowDuplicateName?: boolean;
135
+ }
136
+ /**
137
+ * Thrown by {@link registerRepo} when a requested name is already in
138
+ * use by a DIFFERENT path. The CLI layer surfaces this as an actionable
139
+ * error instead of relying on `.message` string-matching.
140
+ *
141
+ * The colliding alias is exposed as `err.registryName` (not `err.name`).
142
+ * `err.name` keeps its inherited `Error.prototype.name` semantics (the
143
+ * class name) so downstream code can do the usual `err.name ===
144
+ * 'RegistryNameCollisionError'` checks; use the `kind` discriminant or
145
+ * `instanceof RegistryNameCollisionError` for type-safe narrowing.
146
+ */
147
+ export declare class RegistryNameCollisionError extends Error {
148
+ readonly registryName: string;
149
+ readonly existingPath: string;
150
+ readonly requestedPath: string;
151
+ readonly kind: "RegistryNameCollisionError";
152
+ constructor(registryName: string, existingPath: string, requestedPath: string);
153
+ }
105
154
  /**
106
155
  * Register (add or update) a repo in the global registry.
107
156
  * Called after `gitnexus analyze` completes.
108
- */
109
- export declare const registerRepo: (repoPath: string, meta: RepoMeta) => Promise<void>;
157
+ *
158
+ * Name resolution precedence (#829):
159
+ * 1. explicit `opts.name` (from `analyze --name <alias>`)
160
+ * 2. preserved alias on an existing entry for this path
161
+ * 3. `path.basename(repoPath)` (the original default)
162
+ *
163
+ * Duplicate-name guard: if another path already uses the resolved
164
+ * `name`, throw {@link RegistryNameCollisionError} unless
165
+ * `opts.allowDuplicateName` is set. The guard ONLY fires when the user explicitly passed a
166
+ * `name`; un-aliased basename collisions continue to register silently
167
+ * so existing users who don't know about `--name` see no behaviour
168
+ * change.
169
+ */
170
+ export declare const registerRepo: (repoPath: string, meta: RepoMeta, opts?: RegisterRepoOptions) => Promise<void>;
110
171
  /**
111
172
  * Remove a repo from the global registry.
112
173
  * Called after `gitnexus clean`.
@@ -196,20 +196,82 @@ const writeRegistry = async (entries) => {
196
196
  await fs.mkdir(dir, { recursive: true });
197
197
  await fs.writeFile(getGlobalRegistryPath(), JSON.stringify(entries, null, 2), 'utf-8');
198
198
  };
199
+ /**
200
+ * Thrown by {@link registerRepo} when a requested name is already in
201
+ * use by a DIFFERENT path. The CLI layer surfaces this as an actionable
202
+ * error instead of relying on `.message` string-matching.
203
+ *
204
+ * The colliding alias is exposed as `err.registryName` (not `err.name`).
205
+ * `err.name` keeps its inherited `Error.prototype.name` semantics (the
206
+ * class name) so downstream code can do the usual `err.name ===
207
+ * 'RegistryNameCollisionError'` checks; use the `kind` discriminant or
208
+ * `instanceof RegistryNameCollisionError` for type-safe narrowing.
209
+ */
210
+ export class RegistryNameCollisionError extends Error {
211
+ registryName;
212
+ existingPath;
213
+ requestedPath;
214
+ kind = 'RegistryNameCollisionError';
215
+ constructor(registryName, existingPath, requestedPath) {
216
+ super(`Registry name "${registryName}" is already used by "${existingPath}".\n` +
217
+ `Pass --name <alias> to register "${requestedPath}" under a different name, ` +
218
+ `or --allow-duplicate-name to allow both paths under the same name (leaves -r <name> ambiguous for these two).`);
219
+ this.registryName = registryName;
220
+ this.existingPath = existingPath;
221
+ this.requestedPath = requestedPath;
222
+ this.name = 'RegistryNameCollisionError';
223
+ }
224
+ }
225
+ /** Returns true when a previously-registered entry's `name` differs from
226
+ * `path.basename(entry.path)` — i.e. a user explicitly aliased it via
227
+ * `analyze --name <alias>` on a prior run. Used to preserve the alias
228
+ * across re-analyses that omit `--name`. */
229
+ const hasCustomAlias = (entry) => {
230
+ return entry.name !== path.basename(path.resolve(entry.path));
231
+ };
199
232
  /**
200
233
  * Register (add or update) a repo in the global registry.
201
234
  * Called after `gitnexus analyze` completes.
235
+ *
236
+ * Name resolution precedence (#829):
237
+ * 1. explicit `opts.name` (from `analyze --name <alias>`)
238
+ * 2. preserved alias on an existing entry for this path
239
+ * 3. `path.basename(repoPath)` (the original default)
240
+ *
241
+ * Duplicate-name guard: if another path already uses the resolved
242
+ * `name`, throw {@link RegistryNameCollisionError} unless
243
+ * `opts.allowDuplicateName` is set. The guard ONLY fires when the user explicitly passed a
244
+ * `name`; un-aliased basename collisions continue to register silently
245
+ * so existing users who don't know about `--name` see no behaviour
246
+ * change.
202
247
  */
203
- export const registerRepo = async (repoPath, meta) => {
248
+ export const registerRepo = async (repoPath, meta, opts) => {
204
249
  const resolved = path.resolve(repoPath);
205
- const name = path.basename(resolved);
206
250
  const { storagePath } = getStoragePaths(resolved);
207
251
  const entries = await readRegistry();
208
- const existing = entries.findIndex((e) => {
252
+ const existingIdx = entries.findIndex((e) => {
209
253
  const a = path.resolve(e.path);
210
254
  const b = resolved;
211
255
  return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
212
256
  });
257
+ const existing = existingIdx >= 0 ? entries[existingIdx] : null;
258
+ // Precedence: explicit --name > preserved alias > basename.
259
+ const name = opts?.name ?? (existing && hasCustomAlias(existing) ? existing.name : path.basename(resolved));
260
+ // Duplicate-name guard: only fire when the user EXPLICITLY asked for
261
+ // this name (via opts.name or a preserved alias). Unqualified basename
262
+ // collisions are preserved for backward-compat — they still register,
263
+ // and the user sees the ambiguity at `-r` / `list` resolution time
264
+ // (which is already improved by the disambiguated error messages and
265
+ // list output this PR also ships).
266
+ const explicitName = opts?.name !== undefined || (existing && hasCustomAlias(existing));
267
+ if (explicitName && !opts?.allowDuplicateName) {
268
+ const collidingEntry = entries.find((e, i) => i !== existingIdx &&
269
+ e.name.toLowerCase() === name.toLowerCase() &&
270
+ path.resolve(e.path) !== resolved);
271
+ if (collidingEntry) {
272
+ throw new RegistryNameCollisionError(name, collidingEntry.path, resolved);
273
+ }
274
+ }
213
275
  const entry = {
214
276
  name,
215
277
  path: resolved,
@@ -218,8 +280,8 @@ export const registerRepo = async (repoPath, meta) => {
218
280
  lastCommit: meta.lastCommit,
219
281
  stats: meta.stats,
220
282
  };
221
- if (existing >= 0) {
222
- entries[existing] = entry;
283
+ if (existingIdx >= 0) {
284
+ entries[existingIdx] = entry;
223
285
  }
224
286
  else {
225
287
  entries.push(entry);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.3-rc.17",
3
+ "version": "1.6.3-rc.19",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",