mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
fa39a4b4a5
commit
9926804d75
5 changed files with 294 additions and 23 deletions
4
gitnexus/package-lock.json
generated
4
gitnexus/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue