Merge branch 'main' into fix/ruby-gem-require-boundary

This commit is contained in:
Gergő Magyar 2026-08-30 09:08:23 +01:00 • committed by GitHub
commit 8d2de2205a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 943 additions and 195 deletions

View file

@ -57,7 +57,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.4",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.10.1",
"@vercel/node": "^5.10.2",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "^4.1.9",
"jsdom": "^29.1.1",
@ -307,13 +307,6 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@bytecodealliance/preview2-shim": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.6.tgz",
"integrity": "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw==",
"dev": true,
"license": "(Apache-2.0 WITH LLVM-exception)"
},
"node_modules/@cfworker/json-schema": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
@ -1419,17 +1412,6 @@
"node": ">=20"
}
},
"node_modules/@renovatebot/pep440": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.2.1.tgz",
"integrity": "sha512-2FK1hF93Fuf1laSdfiEmJvSJPVIDHEUTz68D3Fi9s0IZrrpaEcj6pTFBTbYvsgC5du4ogrtf5re7yMMvrKNgkw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.9.0 || ^22.11.0 || ^24",
"pnpm": "^10.0.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@ -1517,9 +1499,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1536,9 +1515,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1555,9 +1531,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1574,9 +1547,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1593,9 +1563,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1612,9 +1579,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1865,9 +1829,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1884,9 +1845,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1903,9 +1861,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1922,9 +1877,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2641,13 +2593,12 @@
}
},
"node_modules/@vercel/build-utils": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.1.1.tgz",
"integrity": "sha512-kW9CeW0aokEBvX1rSgNyOKg90VyIQOmT0wBl7KXneM3Qs1+x4Puakqp97BdIgttWEtmN96UvdhQVG2bCA5JsPA==",
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.2.0.tgz",
"integrity": "sha512-GwmtB31tBXQEzFw11grr8BKFCBdUORmYeooB0ZtonaCXZMZaPCHLBFTMFKsvaV6ZciQORPInRwXShbFvmnjqtg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@vercel/python-analysis": "0.13.2",
"cjs-module-lexer": "1.2.3",
"es-module-lexer": "1.5.0"
}
@ -2694,9 +2645,9 @@
}
},
"node_modules/@vercel/node": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.10.1.tgz",
"integrity": "sha512-muj+t8sZ2XHQDkWcHxkql2rbvr/HhZOqYdZBG7pw8F5RLasL3o0gjHLXJKwHAEHp2I3fd3AgbMud2oz+hzeV0g==",
"version": "5.10.2",
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.10.2.tgz",
"integrity": "sha512-YBXcoQVOh5O2ySXvzE+POhPEQEPMJJo4ctlMMdp5why/NIoa8m6gotv14j8Uo6D5qyZsnc+0+++JgUiV4mYB6w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -2704,7 +2655,7 @@
"@edge-runtime/primitives": "4.1.0",
"@edge-runtime/vm": "3.2.0",
"@types/node": "20.11.0",
"@vercel/build-utils": "14.1.1",
"@vercel/build-utils": "14.2.0",
"@vercel/error-utils": "2.2.1",
"@vercel/nft": "1.10.0",
"@vercel/static-config": "3.4.1",
@ -2741,32 +2692,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vercel/python-analysis": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.13.2.tgz",
"integrity": "sha512-IEr5K2gvX143NBoQc1W4BWrdDWjZwxnIT6UrL5Y1dnyH7Cqc4AV00FIAddB1YpnIZBJwT4ZhE8QbgqBeO6C9Zw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@bytecodealliance/preview2-shim": "0.17.6",
"@renovatebot/pep440": "4.2.1",
"fs-extra": "11.1.1",
"js-yaml": "4.1.1",
"minimatch": "10.1.1",
"smol-toml": "1.5.2",
"zod": "3.22.4"
}
},
"node_modules/@vercel/python-analysis/node_modules/zod": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
"integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@vercel/static-config": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.4.1.tgz",
@ -3054,13 +2979,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@ -4514,21 +4432,6 @@
"node": ">=0.4.x"
}
},
"node_modules/fs-extra": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz",
"integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -5200,19 +5103,6 @@
"license": "MIT",
"peer": true
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
@ -5268,9 +5158,9 @@
}
},
"node_modules/jsdom/node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
@ -5322,19 +5212,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/katex": {
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
@ -6887,9 +6764,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@ -7808,19 +7685,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/smol-toml": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -7955,9 +7819,9 @@
}
},
"node_modules/tar": {
"version": "7.5.20",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
"version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@ -8193,9 +8057,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "6.24.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.0.tgz",
"integrity": "sha512-lVLNosgqo5EkGqh5XUDhGfsMSoO8K0BAN0TyJLvwNRSl4xWGZlCVYsAIpa/OpA3TvmnM01GWcoKmc3ZWo5wKKA==",
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"dev": true,
"license": "MIT",
"engines": {
@ -8296,16 +8160,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",

View file

@ -67,7 +67,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.4",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.10.1",
"@vercel/node": "^5.10.2",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "^4.1.9",
"jsdom": "^29.1.1",
@ -83,7 +83,7 @@
},
"@vercel/node": {
"path-to-regexp": "6.3.0",
"undici": "6.24.0"
"undici": "6.28.0"
},
"@vercel/python-analysis": {
"minimatch": "10.2.3",

View file

@ -4521,9 +4521,9 @@
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
"version": "7.6.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
"integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"optional": true,

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-');