mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
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:
parent
5bad2d8b0b
commit
7e993ab897
22 changed files with 918 additions and 24 deletions
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[]) || [];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
273
gitnexus/test/unit/group/sync-registry-identity.test.ts
Normal file
273
gitnexus/test/unit/group/sync-registry-identity.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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-');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue