Skip to content
Merged
46 changes: 45 additions & 1 deletion gitnexus/src/cli/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import { execFileSync } from 'child_process';
import v8 from 'v8';
import cliProgress from 'cli-progress';
import { closeLbug } from '../core/lbug/lbug-adapter.js';
import { getStoragePaths, getGlobalRegistryPath } from '../storage/repo-manager.js';
import {
getStoragePaths,
getGlobalRegistryPath,
RegistryNameCollisionError,
} from '../storage/repo-manager.js';
import { getGitRoot, hasGitDir } from '../storage/git.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import fs from 'fs/promises';
Expand Down Expand Up @@ -59,6 +63,21 @@ export interface AnalyzeOptions {
noStats?: boolean;
/** Index the folder even when no .git directory is present. */
skipGit?: boolean;
/**
* Override the default basename-derived registry `name` with a
* user-supplied alias (#829). Disambiguates repos whose paths share a
* basename. Persisted — subsequent re-analyses of the same path without
* `--name` preserve the alias.
*/
name?: string;
/**
* Allow registration even when another path already uses the same
* `--name` alias (#829). Intentionally a distinct flag from `--force`
* because the user may want to coexist under the same name WITHOUT
* paying the cost of a pipeline re-index. Maps to registerRepo's
* `allowDuplicateName` option end-to-end.
*/
allowDuplicateName?: boolean;
}

export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
Expand Down Expand Up @@ -186,11 +205,20 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
const result = await runFullAnalysis(
repoPath,
{
// Pipeline re-index — OR'd with --skills because skill generation
// needs a fresh pipelineResult. Has no bearing on the registry
// collision guard (see allowDuplicateName below).
force: options?.force || options?.skills,
embeddings: options?.embeddings,
skipGit: options?.skipGit,
skipAgentsMd: options?.skipAgentsMd,
noStats: options?.noStats,
registryName: options?.name,
// Registry-collision bypass — its own CLI flag, intentionally NOT
// overloading --force. A user who hits the collision guard should
// be able to accept the duplicate name without also paying the
// cost of a full pipeline re-index. See #829 review round 2.
allowDuplicateName: options?.allowDuplicateName,
},
{
onProgress: (_phase, percent, message) => {
Expand Down Expand Up @@ -298,6 +326,22 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
bar.stop();

const msg = err.message || String(err);

// Registry name-collision from --name (#829) — surface as an
// actionable error rather than a generic stack-trace.
if (err instanceof RegistryNameCollisionError) {
console.error(`\n Registry name collision:\n`);
console.error(` "${err.registryName}" is already used by "${err.existingPath}".\n`);
console.error(` Options:`);
console.error(` • Pick a different alias: gitnexus analyze --name <alias>`);
console.error(
` • Allow the duplicate: gitnexus analyze --allow-duplicate-name (leaves "-r ${err.registryName}" ambiguous)`,
);
console.error('');
process.exitCode = 1;
return;
}

console.error(`\n Analysis failed: ${msg}\n`);

// Provide helpful guidance for known failure modes
Expand Down
10 changes: 10 additions & 0 deletions gitnexus/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ program
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
.option('--skip-git', 'Index a folder without requiring a .git directory')
.option(
'--name <alias>',
'Register this repo under a custom name in ~/.gitnexus/registry.json ' +
'(disambiguates repos whose paths share a basename, e.g. two different .../app folders)',
)
.option(
'--allow-duplicate-name',
'Register this repo even if another path already uses the same --name alias. ' +
'Leaves `-r <name>` ambiguous for the two paths; use -r <path> to disambiguate.',
)
.option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
.addHelpText(
'after',
Expand Down
13 changes: 12 additions & 1 deletion gitnexus/src/cli/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,23 @@ export const listCommand = async () => {

console.log(`\n Indexed Repositories (${entries.length})\n`);

// Count occurrences of each name so colliding entries can be
// disambiguated in the header (#829). Unique-name entries render
// identically to pre-#829 output; only collisions gain a suffix.
const nameCounts = new Map<string, number>();
for (const e of entries) {
const key = e.name.toLowerCase();
nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1);
}

for (const entry of entries) {
const indexedDate = new Date(entry.indexedAt).toLocaleString();
const stats = entry.stats || {};
const commitShort = entry.lastCommit?.slice(0, 7) || 'unknown';
const hasCollision = (nameCounts.get(entry.name.toLowerCase()) ?? 0) > 1;
const header = hasCollision ? `${entry.name} (${entry.path})` : entry.name;

console.log(` ${entry.name}`);
console.log(` ${header}`);
console.log(` Path: ${entry.path}`);
console.log(` Indexed: ${indexedDate}`);
console.log(` Commit: ${commitShort}`);
Expand Down
31 changes: 30 additions & 1 deletion gitnexus/src/core/run-analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,34 @@ export interface AnalyzeCallbacks {
}

export interface AnalyzeOptions {
/**
* Force a full re-index of the pipeline. Callers may OR this with
* other flags that imply re-analysis (e.g. `--skills`), so the value
* here is the PIPELINE-force signal, NOT the registry-collision
* bypass. See `allowDuplicateName` below.
*/
force?: boolean;
embeddings?: boolean;
skipGit?: boolean;
/** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */
skipAgentsMd?: boolean;
/** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
noStats?: boolean;
/**
* User-provided alias for the registry `name` (#829). When set,
* forwarded to `registerRepo` so the indexed repo is stored under
* this alias instead of the path-derived basename.
*/
registryName?: string;
/**
* Bypass the `RegistryNameCollisionError` guard and allow two paths
* to register under the same `name` (#829). Controlled by the
* dedicated `--allow-duplicate-name` CLI flag, intentionally
* independent from `--force` — users who hit the collision guard
* should be able to accept the duplicate without paying the cost
* of a pipeline re-index.
*/
allowDuplicateName?: boolean;
}

export interface AnalyzeResult {
Expand Down Expand Up @@ -313,7 +334,15 @@ export async function runFullAnalysis(
},
};
await saveMeta(storagePath, meta);
await registerRepo(repoPath, meta);
// Forward the --name alias and the registry-collision bypass bit.
// `allowDuplicateName` is its own concern — independent from the
// pipeline `force` above. The CLI maps it from
// `--allow-duplicate-name` only; `--force` and `--skills` both
// trigger pipeline re-run but never bypass the registry guard.
await registerRepo(repoPath, meta, {
name: options.registryName,
allowDuplicateName: options.allowDuplicateName,
});

// Only attempt to update .gitignore when a .git directory is present.
if (hasGitDir(repoPath)) {
Expand Down
20 changes: 16 additions & 4 deletions gitnexus/src/mcp/local/local-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,13 +319,25 @@ export class LocalBackend {
if (this.repos.size === 0) {
throw new Error('No indexed repositories. Run: gitnexus analyze');
}

// Build a disambiguated "Available: …" list (#829). When two handles
// share a name, annotate each colliding label with its path so the
// caller can actually pick the right one. Single-name entries render
// identically to pre-#829 output.
const nameCounts = new Map<string, number>();
for (const h of this.repos.values()) {
const key = h.name.toLowerCase();
nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1);
}
const labels = [...this.repos.values()].map((h) =>
(nameCounts.get(h.name.toLowerCase()) ?? 0) > 1 ? `${h.name} (${h.repoPath})` : h.name,
);

if (repoParam) {
const names = [...this.repos.values()].map((h) => h.name);
throw new Error(`Repository "${repoParam}" not found. Available: ${names.join(', ')}`);
throw new Error(`Repository "${repoParam}" not found. Available: ${labels.join(', ')}`);
}
const names = [...this.repos.values()].map((h) => h.name);
throw new Error(
`Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${names.join(', ')}`,
`Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${labels.join(', ')}`,
);
}

Expand Down
116 changes: 111 additions & 5 deletions gitnexus/src/storage/repo-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,21 +244,127 @@ const writeRegistry = async (entries: RegistryEntry[]): Promise<void> => {
await fs.writeFile(getGlobalRegistryPath(), JSON.stringify(entries, null, 2), 'utf-8');
};

/**
* Options for {@link registerRepo}. All optional — callers without any
* disambiguation requirement can keep calling `registerRepo(path, meta)`
* unchanged.
*/
export interface RegisterRepoOptions {
/**
* User-provided alias from `analyze --name <alias>` (#829). Overrides
* the default basename-derived registry `name`. Persisted — subsequent
* re-analyses of the same path without `--name` preserve the alias.
*/
name?: string;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I understand correctly I can make multiple analysis on one repo and has multiple aliases with this option?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — no. One repo path always maps to exactly one registry entry, and therefore exactly one alias.

Concretely, registerRepo upserts by resolved path: entries.findIndex(...) on gitnexus/src/storage/repo-manager.ts:329 locates the existing entry for this path, and the write branch at gitnexus/src/storage/repo-manager.ts:368 either updates that slot in place or pushes a new one — never both. So running analyze --name Y on a repo that was previously --name X replaces X with Y rather than adding a second entry. That invariant is locked in by the re-registerRepo with a different name overrides the previous alias test at repo-manager.test.ts:196.

--allow-duplicate-name operates in the orthogonal direction: it permits two different paths to share one alias (rendering -r <alias> ambiguous between those two paths, at which point the user falls back to -r <path>, which already works).

In 553ee0e I expanded the JSDoc on this field to spell that scope out explicitly — "cross-path alias sharing only · one path → one entry always · --name Y on the same path overwrites --name X" — so the next reader doesn't have to reconstruct it from the code.

/**
* Allow two DIFFERENT repo paths to register under the same alias
* (#829). Mapped from the `--allow-duplicate-name` CLI flag.
*
* Scope: this flag governs cross-path alias sharing only — one repo
* path always has exactly one registry entry (and therefore exactly
* one alias). Re-analyzing the same path with `--name Y` overwrites
* a previous `--name X`; it does NOT create a second entry or a
* second alias for the same path (see the upsert-by-resolved-path
* logic in {@link registerRepo} and the
* `re-registerRepo with a different name overrides the previous
* alias` test in `test/unit/repo-manager.test.ts`).
*
* Distinct from `--force` (which only triggers pipeline re-index);
* a user accepting a duplicate alias should not be forced to also
* re-run the full pipeline.
*/
allowDuplicateName?: boolean;
}

/**
* Thrown by {@link registerRepo} when a requested name is already in
* use by a DIFFERENT path. The CLI layer surfaces this as an actionable
* error instead of relying on `.message` string-matching.
*
* The colliding alias is exposed as `err.registryName` (not `err.name`).
* `err.name` keeps its inherited `Error.prototype.name` semantics (the
* class name) so downstream code can do the usual `err.name ===
* 'RegistryNameCollisionError'` checks; use the `kind` discriminant or
* `instanceof RegistryNameCollisionError` for type-safe narrowing.
*/
export class RegistryNameCollisionError extends Error {
readonly kind = 'RegistryNameCollisionError' as const;
constructor(
public readonly registryName: string,
public readonly existingPath: string,
public readonly requestedPath: string,
) {
super(
`Registry name "${registryName}" is already used by "${existingPath}".\n` +
`Pass --name <alias> to register "${requestedPath}" under a different name, ` +
`or --allow-duplicate-name to allow both paths under the same name (leaves -r <name> ambiguous for these two).`,
);
this.name = 'RegistryNameCollisionError';
}
}

/** Returns true when a previously-registered entry's `name` differs from
* `path.basename(entry.path)` — i.e. a user explicitly aliased it via
* `analyze --name <alias>` on a prior run. Used to preserve the alias
* across re-analyses that omit `--name`. */
const hasCustomAlias = (entry: RegistryEntry): boolean => {
return entry.name !== path.basename(path.resolve(entry.path));
};

/**
* Register (add or update) a repo in the global registry.
* Called after `gitnexus analyze` completes.
*
* Name resolution precedence (#829):
* 1. explicit `opts.name` (from `analyze --name <alias>`)
* 2. preserved alias on an existing entry for this path
* 3. `path.basename(repoPath)` (the original default)
*
* Duplicate-name guard: if another path already uses the resolved
* `name`, throw {@link RegistryNameCollisionError} unless
* `opts.allowDuplicateName` is set. The guard ONLY fires when the user explicitly passed a
* `name`; un-aliased basename collisions continue to register silently
* so existing users who don't know about `--name` see no behaviour
* change.
*/
export const registerRepo = async (repoPath: string, meta: RepoMeta): Promise<void> => {
export const registerRepo = async (
repoPath: string,
meta: RepoMeta,
opts?: RegisterRepoOptions,
): Promise<void> => {
const resolved = path.resolve(repoPath);
const name = path.basename(resolved);
const { storagePath } = getStoragePaths(resolved);

const entries = await readRegistry();
const existing = entries.findIndex((e) => {
const existingIdx = entries.findIndex((e) => {
const a = path.resolve(e.path);
const b = resolved;
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
});
const existing = existingIdx >= 0 ? entries[existingIdx] : null;

// Precedence: explicit --name > preserved alias > basename.
const name =
opts?.name ?? (existing && hasCustomAlias(existing) ? existing.name : path.basename(resolved));

// Duplicate-name guard: only fire when the user EXPLICITLY asked for
// this name (via opts.name or a preserved alias). Unqualified basename
// collisions are preserved for backward-compat — they still register,
// and the user sees the ambiguity at `-r` / `list` resolution time
// (which is already improved by the disambiguated error messages and
// list output this PR also ships).
const explicitName = opts?.name !== undefined || (existing && hasCustomAlias(existing));
if (explicitName && !opts?.allowDuplicateName) {
const collidingEntry = entries.find(
(e, i) =>
i !== existingIdx &&
e.name.toLowerCase() === name.toLowerCase() &&
path.resolve(e.path) !== resolved,
);
if (collidingEntry) {
throw new RegistryNameCollisionError(name, collidingEntry.path, resolved);
}
}

const entry: RegistryEntry = {
name,
Expand All @@ -269,8 +375,8 @@ export const registerRepo = async (repoPath: string, meta: RepoMeta): Promise<vo
stats: meta.stats,
};

if (existing >= 0) {
entries[existing] = entry;
if (existingIdx >= 0) {
entries[existingIdx] = entry;
} else {
entries.push(entry);
}
Expand Down
Loading
Loading