feat(cli): infer registry name from git remote.origin.url (#981)

* Initial plan

* Plan: smarter index name inference via git remote URL

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/95064d2d-b1da-4c89-9069-5b3e9cc2636a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* feat(cli): infer registry name from git remote.origin.url (#979)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/95064d2d-b1da-4c89-9069-5b3e9cc2636a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor: skip git subprocess when --name was supplied (review feedback)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/95064d2d-b1da-4c89-9069-5b3e9cc2636a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* style: prettier --write on run-analyze.ts

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a4bf631d-ea6b-4d84-b426-29b1e5c3539f

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
This commit is contained in:
Copilot 2026-04-19 09:14:14 +01:00 committed by GitHub
parent fa39a4b4a5
commit 9926804d75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 294 additions and 23 deletions

View file

@ -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": {

View file

@ -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) {

View file

@ -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;

View file

@ -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 <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
@ -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<void> => {
): Promise<string> => {
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;
};
/**

View file

@ -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<ReturnType<typeof createTempDir>>;
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 <rig>/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();
}
});
});