Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions gitnexus/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions gitnexus/src/core/run-analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
registerRepo,
cleanupOldKuzuFiles,
} from '../storage/repo-manager.js';
import { getCurrentCommit, hasGitDir } from '../storage/git.js';
import { getCurrentCommit, hasGitDir, getInferredRepoName } from '../storage/git.js';
import type { CachedEmbedding } from './embeddings/types.js';
import { generateAIContextFiles } from '../cli/ai-context.js';
import { EMBEDDING_TABLE_NAME } from './lbug/schema.js';
Expand Down Expand Up @@ -152,7 +152,7 @@ export async function runFullAnalysis(
// Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
if (currentCommit !== '') {
return {
repoName: path.basename(repoPath),
repoName: options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(repoPath),
repoPath,
stats: existingMeta.stats ?? {},
alreadyUpToDate: true,
Expand Down Expand Up @@ -339,7 +339,11 @@ export async function runFullAnalysis(
// 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, {
// The returned name is the one actually written to the registry
// (after applying the precedence chain in registerRepo) — reuse it
// so AGENTS.md / skill files reference the same name MCP clients
// will look up (#979).
const projectName = await registerRepo(repoPath, meta, {
name: options.registryName,
allowDuplicateName: options.allowDuplicateName,
});
Expand All @@ -349,8 +353,6 @@ export async function runFullAnalysis(
await addToGitignore(repoPath);
}

const projectName = path.basename(repoPath);

// ── Generate AI context files (best-effort) ───────────────────────
let aggregatedClusterCount = 0;
if (pipelineResult.communityResult?.communities) {
Expand Down
52 changes: 52 additions & 0 deletions gitnexus/src/storage/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,58 @@ export const hasGitDir = (dirPath: string): boolean => {
}
};

/**
* Read `remote.origin.url` from a git repository, or `null` if not a
* git repo, has no `origin` remote, or git is unavailable.
*
* Used by the registry-name inference path (#979) to recover a
* meaningful repo name when `path.basename(repoPath)` is generic
* (e.g. monorepo subprojects, git worktrees, Gas-Town-style
* `<rig>/refinery/rig/` layouts).
*/
export const getRemoteOriginUrl = (repoPath: string): string | null => {
try {
const url = execSync('git config --get remote.origin.url', {
cwd: repoPath,
stdio: ['ignore', 'pipe', 'ignore'],
})
.toString()
.trim();
return url || null;
} catch {
return null;
}
};

/**
* Parse a repository name out of a git remote URL. Handles the common
* SSH (`git@host:owner/repo.git`), HTTPS (`https://host/owner/repo.git`),
* `git://`, `ssh://`, and `file://` shapes. Returns `null` for empty /
* unparseable input.
*
* The heuristic: strip a trailing `.git` and trailing slashes, then
* take the segment after the last `/` or `:`.
*/
export const parseRepoNameFromUrl = (url: string | null | undefined): string | null => {
if (!url) return null;
const trimmed = url.trim();
if (!trimmed) return null;
// Strip `.git` suffix (case-insensitive) and any trailing slashes.
const withoutSuffix = trimmed.replace(/\.git\/*$/i, '').replace(/\/+$/, '');
// Last path segment, splitting on either `/` or `:` (covers SSH form).
const m = withoutSuffix.match(/[/:]([^/:]+)$/);
const candidate = m ? m[1] : withoutSuffix;
return candidate || null;
};

/**
* Convenience wrapper: derive a registry-friendly name from the repo's
* `origin` remote, or `null` when it cannot be inferred.
*/
export const getInferredRepoName = (repoPath: string): string | null => {
return parseRepoNameFromUrl(getRemoteOriginUrl(repoPath));
};

export interface DiffHunk {
startLine: number;
endLine: number;
Expand Down
67 changes: 51 additions & 16 deletions gitnexus/src/storage/repo-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { getInferredRepoName } from './git.js';

export interface RepoMeta {
repoPath: string;
Expand Down Expand Up @@ -304,34 +305,50 @@ export class RegistryNameCollisionError extends Error {
}

/** 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));
* both `path.basename(entry.path)` and the git-remote-derived name —
* 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`. The remote-derived name is treated as an inference, not a
* custom alias, so re-analyses keep tracking remote renames.
*
* `inferredName` is passed in (rather than re-derived) so callers can
* avoid a second `git config` subprocess invocation. */
const hasCustomAlias = (entry: RegistryEntry, inferredName: string | null): boolean => {
const resolved = path.resolve(entry.path);
if (entry.name === path.basename(resolved)) return false;
if (inferredName && entry.name === inferredName) return false;
return true;
};

/**
* Register (add or update) a repo in the global registry.
* Called after `gitnexus analyze` completes.
*
* Name resolution precedence (#829):
* Name resolution precedence (#829, #979):
* 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)
* 3. `git config --get remote.origin.url` repo name (#979 — recovers
* a meaningful name for monorepo subprojects, git worktrees, and
* Gas-Town-style `<rig>/refinery/rig/` layouts where the basename
* is generic)
* 4. `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.
*
* Returns the `name` that was actually written to the registry — the
* caller can re-use it to keep AGENTS.md / skill files aligned with the
* MCP-visible repo name (#979).
*/
export const registerRepo = async (
repoPath: string,
meta: RepoMeta,
opts?: RegisterRepoOptions,
): Promise<void> => {
): Promise<string> => {
const resolved = path.resolve(repoPath);
const { storagePath } = getStoragePaths(resolved);

Expand All @@ -343,17 +360,34 @@ export const registerRepo = async (
});
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));
// Precedence: explicit --name > preserved alias > remote-inferred > basename.
// Skip the `git config` subprocess entirely when --name was passed —
// the remote isn't consulted in that case.
let name: string;
let isPreservedAlias = false;
if (opts?.name !== undefined) {
name = opts.name;
} else {
// Compute the remote-derived name at most once. It feeds both the
// alias-preservation check (`hasCustomAlias` needs it to distinguish
// a sticky user alias from a previously-stored remote inference) and
// the fallback name when neither --name nor a preserved alias apply.
const inferred = getInferredRepoName(resolved);
if (existing && hasCustomAlias(existing, inferred)) {
name = existing.name;
isPreservedAlias = true;
} else {
name = inferred ?? 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));
// and remote-inferred 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 #829 ships).
const explicitName = opts?.name !== undefined || isPreservedAlias;
if (explicitName && !opts?.allowDuplicateName) {
const collidingEntry = entries.find(
(e, i) =>
Expand Down Expand Up @@ -382,6 +416,7 @@ export const registerRepo = async (
}

await writeRegistry(entries);
return name;
};

/**
Expand Down
Loading
Loading