fix(group): fail ambiguous sync names and honor analyze --name (#3094)

* fix(group): fail sync when a member name is ambiguous

Silent first-match bound the wrong clone when --allow-duplicate-name
registered two paths under one alias. Refs #3028.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(analyze): apply --name on the already-up-to-date path

A rename should not require --force when the index is already current.
Register before the same-commit branch restamp. Refs #3028.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(group): hint member path when impact --repo is an alias

$localRepo stays the yaml key; joining on the registry alias is a
non-join. List matching keys so operators can retry. Refs #3028.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(group): keep injected sync and alias hints consistent

Workspace-deps path maps reuse the resolved handle so duplicate names
cannot throw after an injected resolver. Alias hints match case-insensitively.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gergő Magyar 2026-08-29 22:48:46 +01:00 • committed by GitHub
parent 5bad2d8b0b
commit 7e993ab897
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 918 additions and 24 deletions

View file

@ -1436,6 +1436,9 @@ const analyzeCommandImpl = async (
console.error = origError;
bar.stop();
console.log(' Already up to date\n');
if (runOptions.registryName) {
console.log(` Registry name: ${result.repoName}\n`);
}
if (baseRefRefreshed.length > 0) {
console.log(
` Updated base_ref to "${resolvedDefaultBranch}" in ${baseRefRefreshed.join(', ')}\n`,

View file

@ -219,8 +219,9 @@ export function registerGroupCommands(program: Command): void {
.action(async (name: string, opts: Record<string, boolean | undefined>) => {
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const { syncGroup } = await import('../core/group/sync.js');
const { syncGroup, formatGroupSyncAmbiguousError } = await import('../core/group/sync.js');
const { GroupSyncLockError } = await import('../core/group/group-lock.js');
const { RegistryAmbiguousTargetError } = await import('../storage/repo-manager.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
@ -235,6 +236,11 @@ export function registerGroupCommands(program: Command): void {
exactOnly: Boolean(opts.exactOnly),
});
} catch (err) {
if (err instanceof RegistryAmbiguousTargetError) {
logger.error(`⚠️ Did not sync group "${name}": ${formatGroupSyncAmbiguousError(err)}`);
process.exitCode = 1;
return;
}
// A sync that could not take the group's lock did NOT run and wrote
// nothing (R9 fails closed). That is an operator-actionable outcome, not
// a crash, so report it as a failed command rather than letting it

View file

@ -60,7 +60,16 @@ export function parseGroupConfig(yamlContent: string): GroupConfig {
throw new Error('repos is required in group.yaml (must be a mapping)');
}
const repos = raw.repos as Record<string, string>;
const reposRaw = raw.repos as Record<string, unknown>;
const repos: Record<string, string> = {};
for (const [memberPath, registryName] of Object.entries(reposRaw)) {
if (typeof registryName !== 'string' || registryName.trim() === '') {
throw new Error(
`repos["${memberPath}"] must be a non-empty registry name string, not ${typeof registryName}`,
);
}
repos[memberPath] = registryName.trim();
}
const repoPaths = new Set(Object.keys(repos));
const rawLinks = (raw.links as unknown[]) || [];

View file

@ -244,6 +244,17 @@ async function resolveGroupRepo(
): Promise<GroupRepoHandle | { error: string }> {
const registryName = config.repos[repoPath];
if (!registryName) {
const matchingMemberPaths = Object.entries(config.repos)
.filter(([, alias]) => alias.toLowerCase() === repoPath.toLowerCase())
.map(([memberPath]) => memberPath);
if (matchingMemberPaths.length > 0) {
return {
error:
`Unknown repo path "${repoPath}" in this group. ` +
`That value is a registry alias for member path(s): ${matchingMemberPaths.join(', ')}. ` +
`Pass the group.yaml key to --repo, not the alias.`,
};
}
return { error: `Unknown repo path "${repoPath}" in this group.` };
}
try {

View file

@ -479,8 +479,9 @@ export class GroupService {
// group tools never need it — so deferring it here keeps that closure off
// MCP server startup entirely and off every non-sync group call. The CLI
// already does exactly this at `cli/group.ts`'s sync command.
const { syncGroup } = await import('./sync.js');
const { syncGroup, formatGroupSyncAmbiguousError } = await import('./sync.js');
const { GroupSyncLockError } = await import('./group-lock.js');
const { RegistryAmbiguousTargetError } = await import('../../storage/repo-manager.js');
let result: Awaited<ReturnType<typeof syncGroup>>;
try {
result = await syncGroup(config, {
@ -492,6 +493,9 @@ export class GroupService {
// expects. `SyncOptions.verbose` stays for the CLI, which can see them.
});
} catch (err) {
if (err instanceof RegistryAmbiguousTargetError) {
return { error: formatGroupSyncAmbiguousError(err) };
}
// Fails closed (R9): this sync could not be protected against a concurrent
// one, so it did not run and wrote nothing. Return it through the same
// error channel a missing group uses — NEVER as a success payload of zeroes,

View file

@ -8,8 +8,12 @@ import {
getMaxResidentRepos,
} from '../lbug/pool-adapter.js';
import {
findRegistryEntryByName,
canonicalizePath,
registryPathEquals,
readRegistry,
readRegistryStrict,
RegistryAmbiguousTargetError,
type RegistryEntry,
} from '../../storage/repo-manager.js';
import type {
@ -128,9 +132,19 @@ export function stableRepoPoolId(entry: RegistryEntry, allEntries: RegistryEntry
return base;
}
/** Operator copy for group sync — unique `--name`, not a path in yaml. */
export function formatGroupSyncAmbiguousError(err: RegistryAmbiguousTargetError): string {
const listing = err.matches.map((m) => ` - ${m.path}`).join('\n');
return (
`Multiple registered repos are named "${err.target}":\n${listing}\n` +
`Give each clone a unique registry name with \`gitnexus analyze --name\`, then re-sync. ` +
`Do not put a filesystem path in group.yaml.`
);
}
function defaultResolveHandle(allEntries: RegistryEntry[]) {
return async (registryName: string, groupPath: string): Promise<RepoHandle | null> => {
const e = allEntries.find((en) => en.name === registryName);
const e = findRegistryEntryByName(allEntries, registryName);
if (!e) return null;
const poolId = stableRepoPoolId(e, allEntries);
return {
@ -277,7 +291,10 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
// Group-path → pool identity for repos that successfully initialized. Drives
// windowed manifest resolution below (re-init + lease per window). Keyed by
// group path because manifest links reference repos by group path.
const repoHandles = new Map<string, { poolId: string; lbugPath: string }>();
const repoHandles = new Map<string, { poolId: string; lbugPath: string; repoPath: string }>();
// Keep resolved disk paths even when extraction fails and removes the
// corresponding handle; workspace discovery does not need a readable index.
const resolvedRepoPaths = new Map<string, string>();
// Every eviction lease this sync holds. Window loops release their own leases
// (bounding residency); this set is the defensive outer-finally sweep —
// release disposers are idempotent, so double-release is a safe no-op.
@ -295,6 +312,11 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
registryEntries = await readRegistryStrict();
const entries = registryEntries;
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
if (!opts?.resolveRepoHandle) {
for (const regName of Object.values(config.repos)) {
findRegistryEntryByName(entries, regName);
}
}
const httpEx = new HttpRouteExtractor();
const graphqlEx = new GraphqlExtractor();
const grpcEx = new GrpcExtractor();
@ -308,6 +330,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
missingRepos.push(groupPath);
continue;
}
resolvedRepoPaths.set(groupPath, handle.repoPath);
const poolId = handle.id;
const lbugPath = path.join(handle.storagePath, 'lbug');
@ -327,7 +350,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
// resolution no longer reuses these executors — it re-inits + leases
// each repo per window (see windowed resolution below, issue #2189).
// Record the pool identity so windowed resolution can re-init.
repoHandles.set(groupPath, { poolId, lbugPath });
repoHandles.set(groupPath, { poolId, lbugPath, repoPath: handle.repoPath });
const executor: CypherExecutor = (query, params) =>
executeParameterized(poolId, query, params ?? {});
@ -409,7 +432,10 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
lastCommit: m.lastCommit || '',
};
} catch {
const e = entries.find((en) => en.name === regName);
const resolvedHandlePath = canonicalizePath(handle.repoPath);
const e = entries.find((en) =>
registryPathEquals(canonicalizePath(en.path), resolvedHandlePath),
);
repoSnapshots[groupPath] = {
indexedAt: e?.indexedAt || '',
lastCommit: e?.lastCommit || '',
@ -431,6 +457,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
// read. The loop bounds the append by memory instead.
for (const contract of repoContracts) autoContracts.push(contract);
} catch (err) {
if (err instanceof RegistryAmbiguousTargetError) throw err;
// This spans initLbug plus all contract extraction for the repo. The
// error used to be discarded entirely, so the only trace of (say) a
// storage-version mismatch was an empty contracts.json and a later
@ -465,7 +492,13 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
const repoPaths = new Map<string, string>();
if (!registryEntries) registryEntries = await readRegistry();
for (const [groupPath, regName] of Object.entries(config.repos)) {
const e = registryEntries.find((en) => en.name === regName);
const resolvedPath = resolvedRepoPaths.get(groupPath);
if (resolvedPath) {
repoPaths.set(groupPath, resolvedPath);
continue;
}
if (opts?.resolveRepoHandle) continue;
const e = findRegistryEntryByName(registryEntries, regName);
if (e) repoPaths.set(groupPath, e.path);
}

View file

@ -1308,6 +1308,13 @@ async function runFullAnalysisInner(
}
progress('fts', 90, 'Search indexes ready');
progress('done', 100, 'Done');
if (options.registryName) {
await registerRepo(repoPath, existingMeta, {
name: options.registryName,
allowDuplicateName: options.allowDuplicateName,
branch: placement.branch,
});
}
return {
repoName:
options.registryName ??
@ -1687,6 +1694,35 @@ async function runFullAnalysisInner(
// later read on a host where it loads — which is a legitimate, common
// state, and the invariant `analyzer-identity-cli.test.ts` pins.
if (!dirty && !healUnregistered) {
if (options.registryName) {
await registerRepo(repoPath, existingMeta, {
name: options.registryName,
allowDuplicateName: options.allowDuplicateName,
branch: placement.branch,
});
if (!placement.branch) {
try {
await generateAIContextFiles(
repoPath,
storagePath,
options.registryName,
existingMeta.stats ?? {},
undefined,
{
skipAgentsMd: options.skipAgentsMd,
skipSkills: options.skipSkills,
noStats: options.noStats,
defaultBranch: options.defaultBranch,
// Fast path does not re-run PDG. Using `options.pdg` would
// strip PDG bullets from AGENTS.md on a rename-only analyze.
hasPdg: existingMeta.pdg != null,
},
);
} catch {
/* best-effort — never fail the fast path over a context refresh */
}
}
}
// ── #2354: restamp the workspace label on a same-commit branch flip ──
// The flat slot follows the checked-out working tree; a branch switch
// at the SAME commit with a clean tree changes nothing the pipeline

View file

@ -909,7 +909,10 @@ const registerRepoUnlocked = async (
// falling back to `path.resolve` when the path doesn't exist.
const canonicalInput = canonicalizePath(repoPath);
const entries = await readRegistry();
// Mutating writes must not treat an unreadable/truncated registry as empty
// (#3094): lenient `readRegistry()` returns `[]` on parse failure and would
// replace the machine-wide file with only this entry. ENOENT stays empty.
const entries = await readRegistryStrict();
const existingIdx = entries.findIndex((e) => {
// Canonicalise the STORED entry too so pre-canonicalisation
// registries (written by older versions, or paths passed in a
@ -1024,7 +1027,7 @@ const registerRepoUnlocked = async (
// R9): re-derive THIS run's delta against the FRESHEST snapshot so a
// concurrent change to the OTHER axis (a branch upsert vs a primary refresh)
// survives instead of being clobbered by a stale entry-time view.
const fresh = await readRegistry();
const fresh = await readRegistryStrict();
const freshIdx = fresh.findIndex((e) => {
const a = canonicalizePath(e.path);
return registryPathEquals(a, canonicalInput);
@ -1469,6 +1472,27 @@ export const resolveRegistryEntry = (entries: RegistryEntry[], target: string):
throw new RegistryNotFoundError(target, availableNames);
};
/**
* Name-only registry match (the name tier of {@link resolveRegistryEntry},
* without path matching). Used by `group.yaml` member *values*, which are
* registry aliases, not filesystem paths.
*
* Zero matches → `undefined` (caller treats as missing). One match → that
* entry. Two or more → {@link RegistryAmbiguousTargetError}.
*/
export const findRegistryEntryByName = (
entries: RegistryEntry[],
name: string,
): RegistryEntry | undefined => {
const targetLower = name.toLowerCase();
const nameMatches = entries.filter((e) => e.name.toLowerCase() === targetLower);
if (nameMatches.length === 1) return nameMatches[0];
if (nameMatches.length > 1) {
throw new RegistryAmbiguousTargetError(name, nameMatches);
}
return undefined;
};
/**
* List all registered repos from the global registry.
*

View file

@ -55,6 +55,51 @@ describe('group CLI', () => {
expect(l.stdout).toContain('acme');
});
it('sync exits nonzero with formatted copy when a member name is ambiguous', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-cli-amb-'));
try {
fs.mkdirSync(path.join(home, 'groups', 'g1'), { recursive: true });
fs.writeFileSync(
path.join(home, 'groups', 'g1', 'group.yaml'),
`version: 1
name: g1
repos:
demo/api: demo-api
`,
);
const cloneA = path.join(home, 'clone-a');
const cloneB = path.join(home, 'clone-b');
fs.mkdirSync(path.join(cloneA, '.gitnexus'), { recursive: true });
fs.mkdirSync(path.join(cloneB, '.gitnexus'), { recursive: true });
fs.writeFileSync(
path.join(home, 'registry.json'),
JSON.stringify([
{
name: 'demo-api',
path: cloneA,
storagePath: path.join(cloneA, '.gitnexus'),
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'aaa',
},
{
name: 'demo-api',
path: cloneB,
storagePath: path.join(cloneB, '.gitnexus'),
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'bbb',
},
]),
);
const r = runGroupIn(home, ['sync', 'g1']);
expect(r.status).not.toBe(0);
// CLI logs JSON (pino): quotes around the group name are escaped in the byte stream.
expect(`${r.stderr}${r.stdout}`).toMatch(/Did not sync group \\"g1\\"/);
expect(`${r.stderr}${r.stdout}`).toContain('demo-api');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
it('test_create_with_invalid_name_fails', () => {
const result = runGroup(['create', '../../evil']);
expect(result.status).not.toBe(0);

View file

@ -21,7 +21,10 @@ vi.mock('../../src/core/group/storage.js', () => ({
listGroups: listGroupsMock,
}));
vi.mock('../../src/core/group/sync.js', () => ({ syncGroup: syncGroupMock }));
vi.mock('../../src/core/group/sync.js', () => ({
syncGroup: syncGroupMock,
formatGroupSyncAmbiguousError: (err: Error) => err.message,
}));
vi.mock('../../src/core/git-staleness.js', () => ({ checkStaleness: vi.fn() }));
describe('GroupService — missing group error handling', () => {

View file

@ -245,6 +245,25 @@ links:
expect(() => parseGroupConfig('version: 1\nname: test')).toThrow(/repos.*required/i);
});
it('throws when a repos value is not a string (YAML number/boolean)', () => {
expect(() =>
parseGroupConfig(`version: 1
name: test
repos:
app: 12
`),
).toThrow(/non-empty registry name string/);
});
it('trims padded registry aliases so they match the registry name', () => {
const config = parseGroupConfig(`version: 1
name: test
repos:
app: " my-app "
`);
expect(config.repos.app).toBe('my-app');
});
it('allows empty repos object (fresh group before first add)', () => {
const yaml = `version: 1
name: new-group

View file

@ -444,4 +444,102 @@ describe('cross-impact', () => {
cleanup();
}
});
it('hints the yaml member path when --repo is the registry alias', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-ci-alias-'));
const groupDir = path.join(tmpDir, 'groups', 'g1');
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: g1
repos:
demo/api: demo-api
demo/web: demo-web
`,
);
vi.stubEnv('GITNEXUS_HOME', tmpDir);
try {
const port: GroupToolPort = {
resolveRepo: vi.fn(),
impact: vi.fn(),
query: vi.fn(),
impactByUid: vi.fn(),
context: vi.fn(),
};
const r = await runGroupImpact(
{ port, gitnexusDir: tmpDir },
{
name: 'g1',
repo: 'demo-api',
target: 'Sym',
direction: 'upstream',
},
);
expect('error' in r).toBe(true);
if ('error' in r) {
expect(r.error).toContain('demo/api');
expect(r.error).toMatch(/registry alias/i);
expect(r.error).not.toContain('demo/web');
}
const mixedCase = await runGroupImpact(
{ port, gitnexusDir: tmpDir },
{
name: 'g1',
repo: 'Demo-API',
target: 'Sym',
direction: 'upstream',
},
);
expect('error' in mixedCase).toBe(true);
if ('error' in mixedCase) {
expect(mixedCase.error).toContain('demo/api');
}
} finally {
vi.unstubAllEnvs();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('lists every member path that shares the same registry alias', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-ci-alias-dup-'));
const groupDir = path.join(tmpDir, 'groups', 'g1');
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: g1
repos:
demo/api: shared
demo/other: shared
`,
);
vi.stubEnv('GITNEXUS_HOME', tmpDir);
try {
const port: GroupToolPort = {
resolveRepo: vi.fn(),
impact: vi.fn(),
query: vi.fn(),
impactByUid: vi.fn(),
context: vi.fn(),
};
const r = await runGroupImpact(
{ port, gitnexusDir: tmpDir },
{
name: 'g1',
repo: 'shared',
target: 'Sym',
direction: 'upstream',
},
);
expect('error' in r).toBe(true);
if ('error' in r) {
expect(r.error).toContain('demo/api');
expect(r.error).toContain('demo/other');
}
} finally {
vi.unstubAllEnvs();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});

View file

@ -127,4 +127,60 @@ describe('resolveBridgeNeighbors', () => {
expect(rows).toEqual([]);
await closeBridgeDb(handle!);
});
itLbugReopen(
'registry alias as localRepo does not join contracts stamped with the member path',
async () => {
const consumer = makeContract({
repo: 'demo/api',
role: 'consumer',
symbolUid: 'consumer-uid',
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
symbolName: 'fetchUsers',
contractId: 'http::GET::/api/users',
confidence: 0.5,
});
const provider = makeContract({
repo: 'demo/api',
role: 'provider',
symbolUid: 'provider-uid',
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
symbolName: 'getUsers',
contractId: 'http::GET::/api/users',
confidence: 0.9,
});
const link: CrossLink = {
from: { repo: 'web', symbolUid: 'web-uid', symbolRef: consumer.symbolRef },
to: { repo: 'demo/api', symbolUid: 'provider-uid', symbolRef: provider.symbolRef },
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'manifest',
confidence: 0.9,
};
await writeBridge(tmpDir, {
contracts: [{ ...consumer, repo: 'web' }, provider],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
const aliasMiss = await resolveBridgeNeighbors(handle!, {
localRepo: 'demo-api',
uids: ['provider-uid'],
direction: 'upstream',
});
expect(aliasMiss).toEqual([]);
const pathHit = await resolveBridgeNeighbors(handle!, {
localRepo: 'demo/api',
uids: ['provider-uid'],
direction: 'upstream',
});
expect(pathHit).toHaveLength(1);
expect(pathHit[0]).toMatchObject({
neighborRepo: 'web',
matchType: 'manifest',
});
await closeBridgeDb(handle!);
},
);
});

View file

@ -50,6 +50,7 @@ const syncGroupMock = vi.fn<() => Promise<SyncResult>>();
vi.mock('../../../src/core/group/sync.js', () => ({
syncGroup: (...args: unknown[]) => syncGroupMock(...(args as [])),
formatGroupSyncAmbiguousError: (err: Error) => err.message,
}));
const { GroupService } = await import('../../../src/core/group/service.js');

View file

@ -54,10 +54,14 @@ vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({
getMaxResidentRepos: vi.fn(() => 5),
}));
vi.mock('../../../src/storage/repo-manager.js', () => ({
readRegistry: vi.fn(async () => []),
readRegistryStrict: vi.fn(async () => []),
}));
vi.mock('../../../src/storage/repo-manager.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/storage/repo-manager.js')>();
return {
...actual,
readRegistry: vi.fn(async () => []),
readRegistryStrict: vi.fn(async () => []),
};
});
vi.mock('../../../src/core/group/extractors/http-route-extractor.js', () => ({
HttpRouteExtractor: class {

View file

@ -0,0 +1,273 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs/promises';
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import { syncGroup } from '../../../src/core/group/sync.js';
import { RegistryAmbiguousTargetError } from '../../../src/storage/repo-manager.js';
import { createTempDir } from '../../helpers/test-db.js';
import type { GroupConfig } from '../../../src/core/group/types.js';
import { GroupService } from '../../../src/core/group/service.js';
import type { GroupToolPort } from '../../../src/core/group/service.js';
const initLbugMock = vi.fn(async () => {});
vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({
initLbug: (...args: unknown[]) => initLbugMock(...args),
executeParameterized: vi.fn(async () => []),
pinRepo: vi.fn(() => () => {}),
getMaxResidentRepos: vi.fn(() => 5),
}));
const makeConfig = (repos: Record<string, string>, extra?: Partial<GroupConfig>): GroupConfig => ({
version: 1,
name: 'test',
description: '',
repos,
links: [],
packages: {},
detect: {
http: false,
graphql: false,
grpc: false,
thrift: false,
topics: false,
includes: false,
workspace_deps: false,
},
matching: {},
...extra,
});
const row = (
tmpHome: string,
name: string,
clone: string,
): {
name: string;
path: string;
storagePath: string;
indexedAt: string;
lastCommit: string;
} => {
mkdirSync(path.join(tmpHome, 'repos', clone), { recursive: true });
return {
name,
path: path.join(tmpHome, 'repos', clone),
storagePath: path.join(tmpHome, 'repos', clone, '.gitnexus'),
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'abc123',
};
};
describe('syncGroup registry name identity', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let savedGitnexusHome: string | undefined;
let registryPath: string;
beforeEach(async () => {
initLbugMock.mockReset();
initLbugMock.mockResolvedValue(undefined);
tmpHome = await createTempDir('gitnexus-sync-registry-id-');
savedGitnexusHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
registryPath = path.join(tmpHome.dbPath, 'registry.json');
});
afterEach(async () => {
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedGitnexusHome;
await tmpHome.cleanup();
});
it('throws RegistryAmbiguousTargetError and does not rewrite group dir files', async () => {
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
await fs.writeFile(registryPath, JSON.stringify([a, b]));
const groupDir = path.join(tmpHome.dbPath, 'groups', 'g');
await fs.mkdir(groupDir, { recursive: true });
const contractsPath = path.join(groupDir, 'contracts.json');
const prior = '{"contracts":[],"crossLinks":[],"marker":"keep"}\n';
await fs.writeFile(contractsPath, prior);
await expect(syncGroup(makeConfig({ 'demo/api': 'demo-api' }), { groupDir })).rejects.toSatisfy(
(err: unknown) => {
expect(err).toBeInstanceOf(RegistryAmbiguousTargetError);
const amb = err as RegistryAmbiguousTargetError;
expect(amb.matches).toHaveLength(2);
expect(amb.matches.map((m) => m.path).sort()).toEqual([a.path, b.path].sort());
return true;
},
);
expect(await fs.readFile(contractsPath, 'utf-8')).toBe(prior);
await expect(fs.access(path.join(groupDir, 'bridge.lbug'))).rejects.toThrow();
});
it('records an unknown yaml value as missing and still extracts other members', async () => {
const known = row(tmpHome.dbPath, 'backend-repo', 'backend');
await fs.writeFile(registryPath, JSON.stringify([known]));
const result = await syncGroup(
makeConfig({ 'app/backend': 'backend-repo', 'app/ghost': 'ghost' }),
{ skipWrite: true },
);
expect(result.missingRepos).toEqual(['app/ghost']);
expect(result.unreadableRepos).toEqual([]);
expect(result.repoSnapshots['app/backend']).toEqual({
indexedAt: known.indexedAt,
lastCommit: known.lastCommit,
});
});
it('treats mixed missing and ambiguous names as a terminal ambiguity with no write', async () => {
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
await fs.writeFile(registryPath, JSON.stringify([a, b]));
const groupDir = path.join(tmpHome.dbPath, 'groups', 'g');
await fs.mkdir(groupDir, { recursive: true });
const contractsPath = path.join(groupDir, 'contracts.json');
await fs.writeFile(contractsPath, '{"keep":true}');
await expect(
syncGroup(makeConfig({ 'demo/api': 'demo-api', 'app/ghost': 'ghost' }), { groupDir }),
).rejects.toBeInstanceOf(RegistryAmbiguousTargetError);
expect(await fs.readFile(contractsPath, 'utf-8')).toBe('{"keep":true}');
});
it('injected resolveRepoHandle still bypasses default name matching', async () => {
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
await fs.writeFile(registryPath, JSON.stringify([a, b]));
const result = await syncGroup(makeConfig({ 'demo/api': 'demo-api' }), {
skipWrite: true,
resolveRepoHandle: async (_name, groupPath) => ({
id: 'injected',
path: groupPath,
repoPath: a.path,
storagePath: a.storagePath,
}),
});
expect(result.missingRepos).toEqual([]);
expect(result.unreadableRepos).toEqual([]);
});
it('does not treat a filesystem path yaml value as a registry hit', async () => {
const known = row(tmpHome.dbPath, 'backend-repo', 'backend');
await fs.writeFile(registryPath, JSON.stringify([known]));
const result = await syncGroup(makeConfig({ 'app/backend': known.path }), { skipWrite: true });
expect(result.missingRepos).toEqual(['app/backend']);
expect(result.repoSnapshots['app/backend']).toBeUndefined();
});
it('injected resolveRepoHandle plus workspace_deps does not throw on duplicate names', async () => {
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
await fs.writeFile(registryPath, JSON.stringify([a, b]));
const result = await syncGroup(
makeConfig(
{ 'demo/api': 'demo-api' },
{
detect: {
http: false,
graphql: false,
grpc: false,
thrift: false,
topics: false,
includes: false,
workspace_deps: true,
},
},
),
{
skipWrite: true,
resolveRepoHandle: async (_name, groupPath) => ({
id: 'injected',
path: groupPath,
repoPath: a.path,
storagePath: a.storagePath,
}),
},
);
expect(result.missingRepos).toEqual([]);
});
it('injected resolveRepoHandle plus workspace_deps still bypasses name lookup after extraction failure', async () => {
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
await fs.writeFile(registryPath, JSON.stringify([a, b]));
initLbugMock.mockRejectedValueOnce(new Error('init failed'));
const result = await syncGroup(
makeConfig(
{ 'demo/api': 'demo-api' },
{
detect: {
http: false,
graphql: false,
grpc: false,
thrift: false,
topics: false,
includes: false,
workspace_deps: true,
},
},
),
{
skipWrite: true,
resolveRepoHandle: async (_name, groupPath) => ({
id: 'injected',
path: groupPath,
repoPath: a.path,
storagePath: a.storagePath,
}),
},
);
expect(result.missingRepos).toEqual([]);
expect(result.unreadableRepos).toEqual(['demo/api']);
});
it('MCP groupSync returns { error } for an ambiguous registry name', async () => {
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
await fs.writeFile(registryPath, JSON.stringify([a, b]));
const groupDir = path.join(tmpHome.dbPath, 'groups', 'g1');
await fs.mkdir(groupDir, { recursive: true });
await fs.writeFile(
path.join(groupDir, 'group.yaml'),
`version: 1
name: g1
repos:
demo/api: demo-api
`,
);
const port: GroupToolPort = {
resolveRepo: vi.fn(),
impact: vi.fn(),
query: vi.fn(),
impactByUid: vi.fn(),
context: vi.fn(),
};
const svc = new GroupService(port);
const payload = (await svc.groupSync({ name: 'g1' })) as { error?: string };
expect(payload.error).toBeDefined();
expect(payload.error).toContain('demo-api');
expect(payload.error).toContain(a.path);
expect(payload.error).toContain(b.path);
expect(payload.error).toMatch(/unique registry name/i);
expect(payload.error).not.toMatch(/Pass the absolute path/);
});
});

View file

@ -66,10 +66,14 @@ vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({
getMaxResidentRepos: vi.fn(() => 5),
}));
vi.mock('../../../src/storage/repo-manager.js', () => ({
readRegistry: (...args: unknown[]) => readRegistryLenientMock(...args),
readRegistryStrict: (...args: unknown[]) => readRegistryStrictMock(...args),
}));
vi.mock('../../../src/storage/repo-manager.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/storage/repo-manager.js')>();
return {
...actual,
readRegistry: (...args: unknown[]) => readRegistryLenientMock(...args),
readRegistryStrict: (...args: unknown[]) => readRegistryStrictMock(...args),
};
});
/**
* Armed by the bridge-write-failure suite at the bottom of this file, `null`

View file

@ -156,10 +156,14 @@ vi.mock('../../../src/core/lbug/sidecar-recovery.js', () => ({
// The registry read happens in syncGroup's else branch; resolveRepoHandle is
// supplied, so an empty registry is fine (only the meta.json fallback reads it).
vi.mock('../../../src/storage/repo-manager.js', () => ({
readRegistry: vi.fn().mockResolvedValue([]),
readRegistryStrict: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../../src/storage/repo-manager.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/storage/repo-manager.js')>();
return {
...actual,
readRegistry: vi.fn().mockResolvedValue([]),
readRegistryStrict: vi.fn().mockResolvedValue([]),
};
});
const { syncGroup } = await import('../../../src/core/group/sync.js');
const { closeLbug, getMaxResidentRepos } = await import('../../../src/core/lbug/pool-adapter.js');

View file

@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import { inspect } from 'node:util';
import { readRegistry, readRegistryStrict } from '../../src/storage/repo-manager.js';
import { readRegistry, readRegistryStrict, registerRepo } from '../../src/storage/repo-manager.js';
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js';
import { createTempDir } from '../helpers/test-db.js';
import { syncGroup } from '../../src/core/group/sync.js';
@ -116,6 +116,20 @@ describe('readRegistryStrict', () => {
await expect(readRegistryStrict()).rejects.toThrow();
});
it('registerRepo refuses to overwrite a truncated registry with a single entry', async () => {
const prior = '{"truncated": ';
await fs.writeFile(registryPath, prior);
await expect(
registerRepo('/repos/one', {
repoPath: '/repos/one',
lastCommit: 'abc',
indexedAt: '2026-01-01T00:00:00.000Z',
stats: {},
}),
).rejects.toThrow('registry is corrupt');
expect(await fs.readFile(registryPath, 'utf-8')).toBe(prior);
});
it('throws when a row is missing the fields the resolver needs', async () => {
// `[{}]` is a JSON array, so an array-shape check alone waved it through.
// Every configured repo then failed to resolve and landed in missingRepos;

View file

@ -28,6 +28,7 @@ import {
adoptFlatBranchLabel,
listRegisteredRepos,
resolveRegistryEntry,
findRegistryEntryByName,
canonicalizePath,
registryPathEquals,
cloneDirBelongsToEntry,
@ -1535,6 +1536,13 @@ describe('resolveRegistryEntry (#664)', () => {
expect(resolveRegistryEntry(entries, 'Website')).toBe(entries[2]);
});
it('findRegistryEntryByName is name-only: a filesystem path is a miss, not a path-tier hit', () => {
expect(findRegistryEntryByName(entries, pathA)).toBeUndefined();
expect(findRegistryEntryByName(entries, 'website')).toBe(entries[2]);
expect(findRegistryEntryByName(entries, 'WEBSITE')).toBe(entries[2]);
expect(() => findRegistryEntryByName(entries, 'app')).toThrow(RegistryAmbiguousTargetError);
});
it('path match is case-insensitive on Windows only', () => {
if (process.platform !== 'win32') {
// On POSIX, a differently-cased path must NOT match. Verify by

View file

@ -359,6 +359,52 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
}
});
it('--repair-fts applies analyze --name without a full re-index', async () => {
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter());
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
initialiseSearchFTSStemmer: vi.fn(() => 'porter'),
createSearchFTSIndexes: vi.fn(async () => []),
verifySearchFTSIndexes: vi.fn(async () => []),
}));
vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
ensureGitNexusIgnored: vi.fn(async () => undefined),
}));
const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-name-');
const tmpHome = await createTempDir('gitnexus-run-analyze-repair-name-home-');
const savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
try {
const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath);
await fs.mkdir(storagePath, { recursive: true });
const seeded: RepoMeta = {
repoPath: tmpRepo.dbPath,
lastCommit: 'abc123',
indexedAt: new Date().toISOString(),
stats: { files: 1, nodes: 1, edges: 1 },
};
await saveMeta(storagePath, seeded);
const { registerRepo, readRegistry } = await import('../../src/storage/repo-manager.js');
await registerRepo(tmpRepo.dbPath, seeded, { name: 'old' });
await createPlaceholderGraphStore(lbugPath);
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const result = await runFullAnalysis(
tmpRepo.dbPath,
{ repairFts: true, registryName: 'new' },
{ onProgress: () => {} },
);
expect(result.ftsRepairedOnly).toBe(true);
expect((await readRegistry())[0].name).toBe('new');
} finally {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpHome.cleanup();
await tmpRepo.cleanup();
}
});
it('--repair-fts backfills a full capabilities object when the existing meta predates the field entirely (#2767)', async () => {
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter());
vi.doMock('../../src/core/search/fts-indexes.js', () => ({

View file

@ -14,6 +14,8 @@ import {
loadMeta,
registerRepo,
saveMeta,
readRegistry,
RegistryNameCollisionError,
type RepoMeta,
} from '../../src/storage/repo-manager.js';
import { SCHEMA_FINGERPRINT } from '../../src/core/lbug/schema.js';
@ -90,6 +92,197 @@ describe('run-analyze module', () => {
}
});
it('applies analyze --name on the already-up-to-date path without --force', async () => {
const tmpRepo = await createTempDir('gitnexus-run-analyze-fast-name-');
const tmpHome = await createTempDir('gitnexus-run-analyze-fast-name-home-');
const savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
try {
execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', {
cwd: tmpRepo.dbPath,
stdio: 'pipe',
});
const currentCommit = execSync('git rev-parse HEAD', {
cwd: tmpRepo.dbPath,
encoding: 'utf-8',
}).trim();
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
const meta: RepoMeta = {
repoPath: tmpRepo.dbPath,
lastCommit: currentCommit,
indexedAt: new Date().toISOString(),
schemaFingerprint: SCHEMA_FINGERPRINT,
analysisFeatures: CURRENT_ANALYSIS_FEATURES,
runnerIdentity: currentRunnerIdentity(),
};
await saveMeta(storagePath, meta);
await registerRepo(tmpRepo.dbPath, meta, { name: 'old' });
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const result = await runFullAnalysis(
tmpRepo.dbPath,
{ registryName: 'new' },
{ onProgress: () => {} },
);
expect(result.alreadyUpToDate).toBe(true);
expect(result.repoName).toBe('new');
const entries = await readRegistry();
expect(entries).toHaveLength(1);
expect(entries[0].name).toBe('new');
const agents = await fs.readFile(path.join(tmpRepo.dbPath, 'AGENTS.md'), 'utf-8');
expect(agents).toContain('**new**');
} finally {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpHome.cleanup();
await tmpRepo.cleanup();
}
});
it('repeating the same --name on the fast path is a no-op, not an error', async () => {
const tmpRepo = await createTempDir('gitnexus-run-analyze-fast-name-repeat-');
const tmpHome = await createTempDir('gitnexus-run-analyze-fast-name-repeat-home-');
const savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
try {
execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', {
cwd: tmpRepo.dbPath,
stdio: 'pipe',
});
const currentCommit = execSync('git rev-parse HEAD', {
cwd: tmpRepo.dbPath,
encoding: 'utf-8',
}).trim();
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
const meta: RepoMeta = {
repoPath: tmpRepo.dbPath,
lastCommit: currentCommit,
indexedAt: new Date().toISOString(),
schemaFingerprint: SCHEMA_FINGERPRINT,
analysisFeatures: CURRENT_ANALYSIS_FEATURES,
runnerIdentity: currentRunnerIdentity(),
};
await saveMeta(storagePath, meta);
await registerRepo(tmpRepo.dbPath, meta, { name: 'kept' });
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const result = await runFullAnalysis(
tmpRepo.dbPath,
{ registryName: 'kept' },
{ onProgress: () => {} },
);
expect(result.alreadyUpToDate).toBe(true);
expect((await readRegistry())[0].name).toBe('kept');
} finally {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpHome.cleanup();
await tmpRepo.cleanup();
}
});
it('fast-path --name still collides when another path already owns the alias', async () => {
const tmpA = await createTempDir('gitnexus-run-analyze-fast-name-col-a-');
const tmpB = await createTempDir('gitnexus-run-analyze-fast-name-col-b-');
const tmpHome = await createTempDir('gitnexus-run-analyze-fast-name-col-home-');
const savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
try {
for (const tmp of [tmpA, tmpB]) {
execSync('git init', { cwd: tmp.dbPath, stdio: 'pipe' });
execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', {
cwd: tmp.dbPath,
stdio: 'pipe',
});
}
const commitB = execSync('git rev-parse HEAD', {
cwd: tmpB.dbPath,
encoding: 'utf-8',
}).trim();
const metaA: RepoMeta = {
repoPath: tmpA.dbPath,
lastCommit: 'aaaa',
indexedAt: new Date().toISOString(),
schemaFingerprint: SCHEMA_FINGERPRINT,
analysisFeatures: CURRENT_ANALYSIS_FEATURES,
runnerIdentity: currentRunnerIdentity(),
};
await registerRepo(tmpA.dbPath, metaA, { name: 'new' });
const { storagePath } = getStoragePaths(tmpB.dbPath);
const metaB: RepoMeta = {
repoPath: tmpB.dbPath,
lastCommit: commitB,
indexedAt: new Date().toISOString(),
schemaFingerprint: SCHEMA_FINGERPRINT,
analysisFeatures: CURRENT_ANALYSIS_FEATURES,
runnerIdentity: currentRunnerIdentity(),
};
await saveMeta(storagePath, metaB);
await registerRepo(tmpB.dbPath, metaB, { name: 'old' });
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await expect(
runFullAnalysis(tmpB.dbPath, { registryName: 'new' }, { onProgress: () => {} }),
).rejects.toBeInstanceOf(RegistryNameCollisionError);
} finally {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpHome.cleanup();
await tmpA.cleanup();
await tmpB.cleanup();
}
});
it('plain fast path does not call registerRepo when --name is absent', async () => {
const tmpRepo = await createTempDir('gitnexus-run-analyze-fast-no-name-');
const tmpHome = await createTempDir('gitnexus-run-analyze-fast-no-name-home-');
const savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
try {
execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', {
cwd: tmpRepo.dbPath,
stdio: 'pipe',
});
const currentCommit = execSync('git rev-parse HEAD', {
cwd: tmpRepo.dbPath,
encoding: 'utf-8',
}).trim();
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
const meta: RepoMeta = {
repoPath: tmpRepo.dbPath,
lastCommit: currentCommit,
indexedAt: new Date().toISOString(),
schemaFingerprint: SCHEMA_FINGERPRINT,
analysisFeatures: CURRENT_ANALYSIS_FEATURES,
runnerIdentity: currentRunnerIdentity(),
};
await saveMeta(storagePath, meta);
await registerRepo(tmpRepo.dbPath, meta, { name: 'original' });
const registerSpy = vi.spyOn(
await import('../../src/storage/repo-manager.js'),
'registerRepo',
);
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} });
expect(result.alreadyUpToDate).toBe(true);
expect(registerSpy).not.toHaveBeenCalled();
expect((await readRegistry())[0].name).toBe('original');
registerSpy.mockRestore();
} finally {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpHome.cleanup();
await tmpRepo.cleanup();
}
});
it('resumes a matching embedding checkpoint instead of taking the clean fast path', async () => {
const tmpRepo = await createTempDir('gitnexus-run-analyze-embedding-checkpoint-');
const tmpHome = await createTempDir('gitnexus-run-analyze-embedding-checkpoint-home-');