diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index f2605cb43f..76afc1fde3 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.6.1", + "version": "1.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.6.1", + "version": "1.6.2", "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index b17fb8e573..5c2191003f 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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'; @@ -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, @@ -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, }); @@ -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) { diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index b0e9e6d3e0..c8d05ac4b4 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -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 + * `/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; diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 4ba17b21b7..1b151cec19 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -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; @@ -304,21 +305,33 @@ 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 ` 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 ` 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 `) * 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 `/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 @@ -326,12 +339,16 @@ const hasCustomAlias = (entry: RegistryEntry): boolean => { * `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 => { +): Promise => { const resolved = path.resolve(repoPath); const { storagePath } = getStoragePaths(resolved); @@ -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) => @@ -382,6 +416,7 @@ export const registerRepo = async ( } await writeRegistry(entries); + return name; }; /** diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts index b9eabccb43..83827fc156 100644 --- a/gitnexus/test/unit/repo-manager.test.ts +++ b/gitnexus/test/unit/repo-manager.test.ts @@ -18,6 +18,8 @@ import { RegistryNameCollisionError, type RepoMeta, } from '../../src/storage/repo-manager.js'; +import { parseRepoNameFromUrl, getInferredRepoName } from '../../src/storage/git.js'; +import { execSync } from 'child_process'; import { createTempDir } from '../helpers/test-db.js'; // ─── getStoragePath ────────────────────────────────────────────────── @@ -271,3 +273,183 @@ describe('registerRepo name override + collision guard (#829)', () => { } }); }); + +// ─── parseRepoNameFromUrl + getInferredRepoName (#979) ─────────────── + +describe('parseRepoNameFromUrl', () => { + it('parses HTTPS URLs and strips .git', () => { + expect(parseRepoNameFromUrl('https://github.com/owner/lume_spark.git')).toBe('lume_spark'); + expect(parseRepoNameFromUrl('https://github.com/owner/lume_spark')).toBe('lume_spark'); + }); + + it('parses SSH URLs (git@host:owner/repo.git)', () => { + expect(parseRepoNameFromUrl('git@github.com:owner/lume_spark.git')).toBe('lume_spark'); + expect(parseRepoNameFromUrl('git@gitlab.com:group/sub/lume_spark.git')).toBe('lume_spark'); + }); + + it('parses ssh:// and git:// URLs', () => { + expect(parseRepoNameFromUrl('ssh://git@host.example/owner/lume_spark.git')).toBe('lume_spark'); + expect(parseRepoNameFromUrl('git://host.example/owner/lume_spark.git')).toBe('lume_spark'); + }); + + it('parses local file:// URLs', () => { + expect(parseRepoNameFromUrl('file:///srv/git/lume_spark.git')).toBe('lume_spark'); + }); + + it('handles trailing slashes and mixed-case .git', () => { + expect(parseRepoNameFromUrl('https://github.com/owner/lume_spark.GIT/')).toBe('lume_spark'); + expect(parseRepoNameFromUrl('https://github.com/owner/lume_spark/')).toBe('lume_spark'); + }); + + it('returns null for empty / null / undefined / unparseable input', () => { + expect(parseRepoNameFromUrl('')).toBeNull(); + expect(parseRepoNameFromUrl(' ')).toBeNull(); + expect(parseRepoNameFromUrl(null)).toBeNull(); + expect(parseRepoNameFromUrl(undefined)).toBeNull(); + }); +}); + +describe('getInferredRepoName + registerRepo (#979 — git remote inference)', () => { + let tmpHome: Awaited>; + let savedGitnexusHome: string | undefined; + + const meta: RepoMeta = { + repoPath: '', + lastCommit: 'abc1234', + indexedAt: '2026-04-19T00:00:00.000Z', + stats: { files: 1, nodes: 1 }, + }; + + /** Initialise a real git repo at `dir` with the given remote URL. */ + const initGitRepo = (dir: string, remoteUrl: string | null) => { + execSync('git init -q', { cwd: dir }); + execSync('git config user.email "test@example.com"', { cwd: dir }); + execSync('git config user.name "Test"', { cwd: dir }); + if (remoteUrl) { + execSync(`git remote add origin ${remoteUrl}`, { cwd: dir }); + } + }; + + beforeEach(async () => { + tmpHome = await createTempDir('gitnexus-registry-home-979-'); + savedGitnexusHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = tmpHome.dbPath; + }); + + afterEach(async () => { + if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedGitnexusHome; + await tmpHome.cleanup(); + }); + + it('getInferredRepoName returns null when there is no .git directory', async () => { + const tmp = await createTempDir('gitnexus-no-git-'); + try { + expect(getInferredRepoName(tmp.dbPath)).toBeNull(); + } finally { + await tmp.cleanup(); + } + }); + + it('getInferredRepoName returns null when origin is unset', async () => { + const tmp = await createTempDir('gitnexus-no-origin-'); + try { + initGitRepo(tmp.dbPath, null); + expect(getInferredRepoName(tmp.dbPath)).toBeNull(); + } finally { + await tmp.cleanup(); + } + }); + + it('getInferredRepoName returns the remote repo name when origin is set', async () => { + const tmp = await createTempDir('gitnexus-with-origin-'); + try { + initGitRepo(tmp.dbPath, 'https://github.com/owner/lume_spark.git'); + expect(getInferredRepoName(tmp.dbPath)).toBe('lume_spark'); + } finally { + await tmp.cleanup(); + } + }); + + it('registerRepo derives name from git remote when basename is generic (Gas-Town repro)', async () => { + // Reproduce /refinery/rig/.git layout: leaf basename is "rig", + // but origin URL says "lume_spark". The new precedence MUST pick up + // the remote-derived name instead of the basename. + const root = await createTempDir('gitnexus-gastown-'); + try { + const rigPath = path.join(root.dbPath, 'lume_spark', 'refinery', 'rig'); + await fs.mkdir(rigPath, { recursive: true }); + initGitRepo(rigPath, 'git@github.com:gastown/lume_spark.git'); + + const name = await registerRepo(rigPath, meta); + expect(name).toBe('lume_spark'); + expect(name).not.toBe('rig'); + + const entries = await listRegisteredRepos(); + expect(entries).toHaveLength(1); + expect(entries[0].name).toBe('lume_spark'); + } finally { + await root.cleanup(); + } + }); + + it('two analyze calls of differently-remoted "rig" leaves no longer collide', async () => { + // Without the remote inference both would register as "rig"; with + // inference they pick up their distinct remotes — the original issue. + const root = await createTempDir('gitnexus-gastown-2-'); + try { + const rigA = path.join(root.dbPath, 'lume_spark', 'refinery', 'rig'); + const rigB = path.join(root.dbPath, 'gemba', 'refinery', 'rig'); + await fs.mkdir(rigA, { recursive: true }); + await fs.mkdir(rigB, { recursive: true }); + initGitRepo(rigA, 'git@github.com:gastown/lume_spark.git'); + initGitRepo(rigB, 'git@github.com:gastown/gemba.git'); + + const nameA = await registerRepo(rigA, meta); + const nameB = await registerRepo(rigB, meta); + expect(nameA).toBe('lume_spark'); + expect(nameB).toBe('gemba'); + + const entries = await listRegisteredRepos(); + expect(entries.map((e) => e.name).sort()).toEqual(['gemba', 'lume_spark']); + } finally { + await root.cleanup(); + } + }); + + it('explicit --name still wins over remote inference', async () => { + const tmp = await createTempDir('gitnexus-name-wins-'); + try { + initGitRepo(tmp.dbPath, 'https://github.com/owner/from-remote.git'); + const name = await registerRepo(tmp.dbPath, meta, { name: 'user-alias' }); + expect(name).toBe('user-alias'); + } finally { + await tmp.cleanup(); + } + }); + + it('preserved alias still wins over remote inference on re-analyze', async () => { + const tmp = await createTempDir('gitnexus-preserve-alias-'); + try { + initGitRepo(tmp.dbPath, 'https://github.com/owner/from-remote.git'); + // First analyze sets the alias… + await registerRepo(tmp.dbPath, meta, { name: 'sticky-alias' }); + // …second analyze with no opts must keep it (not silently switch + // to the remote-derived name). + const name = await registerRepo(tmp.dbPath, meta); + expect(name).toBe('sticky-alias'); + } finally { + await tmp.cleanup(); + } + }); + + it('falls back to basename when no .git / no remote is available', async () => { + const tmp = await createTempDir('gitnexus-fallback-basename-'); + try { + const name = await registerRepo(tmp.dbPath, meta); + expect(name).toBe(path.basename(tmp.dbPath)); + } finally { + await tmp.cleanup(); + } + }); +});