mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
Merge branch 'main' into fix/ruby-gem-require-boundary
This commit is contained in:
commit
8d2de2205a
25 changed files with 943 additions and 195 deletions
186
gitnexus-web/package-lock.json
generated
186
gitnexus-web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
6
gitnexus/package-lock.json
generated
6
gitnexus/package-lock.json
generated
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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