Merge remote-tracking branch 'origin' into feat/csharp-scope-resolution

This commit is contained in:
Gergo Magyar 2026-04-21 22:15:58 +01:00
commit 70691378ab
26 changed files with 1237 additions and 170 deletions

View file

@ -14,6 +14,8 @@ coverage
.env.local
.env.*.local
**/*.tsbuildinfo
.gitnexus
gitnexus-web/playwright-report
gitnexus-web/test-results

View file

@ -4,17 +4,18 @@ ARG TARGETPLATFORM
# ── Builder ────────────────────────────────────────────────────────────
# Native modules (tree-sitter-*, onnxruntime-node, node-gyp builds for
# tree-sitter-proto / tree-sitter-swift) require python3 + a C/C++ toolchain.
FROM node:22-alpine AS builder
FROM node:22-trixie-slim AS builder
WORKDIR /app
# Toolchain for node-gyp / native builds.
RUN apk add --no-cache python3 make g++ git
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ git && rm -rf /var/lib/apt/lists/*
# Build gitnexus-shared first — gitnexus depends on it as a workspace.
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
RUN npm ci --prefix gitnexus-shared
COPY gitnexus-shared ./gitnexus-shared
RUN rm -f gitnexus-shared/tsconfig.tsbuildinfo
RUN npm run build --prefix gitnexus-shared
# Copy the full gitnexus package before installing — `npm ci` triggers
@ -28,10 +29,10 @@ RUN npm ci --prefix gitnexus
RUN npm prune --omit=dev --prefix gitnexus
# ── Runtime ────────────────────────────────────────────────────────────
FROM node:22-alpine AS runtime
FROM node:22-trixie-slim AS runtime
# curl for the healthcheck; git so `gitnexus` can clone repos at runtime.
RUN apk add --no-cache curl git
RUN apt-get update && apt-get install -y --no-install-recommends curl git && rm -rf /var/lib/apt/lists/*
WORKDIR /app

View file

@ -51,7 +51,7 @@
"@types/express": "^4.17.21",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.6.0",
"@types/uuid": "^10.0.0",
"@types/uuid": "^11.0.0",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",
@ -1554,9 +1554,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.124.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
"integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
"dev": true,
"license": "MIT",
"funding": {
@ -1628,9 +1628,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
"cpu": [
"arm64"
],
@ -1645,9 +1645,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
"cpu": [
"arm64"
],
@ -1662,9 +1662,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
"cpu": [
"x64"
],
@ -1679,9 +1679,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
"cpu": [
"x64"
],
@ -1696,9 +1696,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
"integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
"cpu": [
"arm"
],
@ -1713,9 +1713,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
"cpu": [
"arm64"
],
@ -1730,9 +1730,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
"cpu": [
"arm64"
],
@ -1747,9 +1747,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
"cpu": [
"ppc64"
],
@ -1764,9 +1764,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
"cpu": [
"s390x"
],
@ -1781,9 +1781,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
"cpu": [
"x64"
],
@ -1798,9 +1798,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
"cpu": [
"x64"
],
@ -1815,9 +1815,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
"cpu": [
"arm64"
],
@ -1832,9 +1832,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
"integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
"cpu": [
"wasm32"
],
@ -1844,10 +1844,10 @@
"dependencies": {
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.3"
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
@ -1862,9 +1862,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
"cpu": [
"arm64"
],
@ -1879,9 +1879,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
"cpu": [
"x64"
],
@ -1896,9 +1896,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
"integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
"dev": true,
"license": "MIT"
},
@ -2097,21 +2097,25 @@
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz",
"integrity": "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==",
"deprecated": "This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT"
"license": "MIT",
"dependencies": {
"uuid": "*"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz",
"integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.4",
"@vitest/utils": "4.1.5",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@ -2125,8 +2129,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.4",
"vitest": "4.1.4"
"@vitest/browser": "4.1.5",
"vitest": "4.1.5"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@ -2135,16 +2139,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@ -2153,13 +2157,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.4",
"@vitest/spy": "4.1.5",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@ -2180,9 +2184,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2193,13 +2197,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.4",
"@vitest/utils": "4.1.5",
"pathe": "^2.0.3"
},
"funding": {
@ -2207,14 +2211,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"@vitest/utils": "4.1.4",
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@ -2223,9 +2227,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"dev": true,
"license": "MIT",
"funding": {
@ -2233,13 +2237,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"@vitest/pretty-format": "4.1.5",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@ -4527,14 +4531,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
"integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.124.0",
"@rolldown/pluginutils": "1.0.0-rc.15"
"@oxc-project/types": "=0.126.0",
"@rolldown/pluginutils": "1.0.0-rc.16"
},
"bin": {
"rolldown": "bin/cli.mjs"
@ -4543,21 +4547,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-x64": "1.0.0-rc.15",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
"@rolldown/binding-android-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
}
},
"node_modules/router": {
@ -5472,17 +5476,17 @@
}
},
"node_modules/vite": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.15",
"tinyglobby": "^0.2.15"
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.16",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
@ -5550,19 +5554,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
"@vitest/pretty-format": "4.1.4",
"@vitest/runner": "4.1.4",
"@vitest/snapshot": "4.1.4",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@ -5590,12 +5594,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.4",
"@vitest/browser-preview": "4.1.4",
"@vitest/browser-webdriverio": "4.1.4",
"@vitest/coverage-istanbul": "4.1.4",
"@vitest/coverage-v8": "4.1.4",
"@vitest/ui": "4.1.4",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View file

@ -97,7 +97,7 @@
"@types/express": "^4.17.21",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.6.0",
"@types/uuid": "^10.0.0",
"@types/uuid": "^11.0.0",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
"tsx": "^4.0.0",

View file

@ -17,7 +17,7 @@ import {
addToGitignore,
registerRepo,
} from '../storage/repo-manager.js';
import { getGitRoot, isGitRepo } from '../storage/git.js';
import { getGitRoot, getRemoteUrl, isGitRepo } from '../storage/git.js';
export interface IndexOptions {
force?: boolean;
@ -107,6 +107,13 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt
}
// ── Register in global registry ───────────────────────────────────
// Refresh the on-disk meta with a freshly captured `remoteUrl` if
// it's missing, so an `index` of an older `.gitnexus/` still gets
// sibling-clone fingerprinting on subsequent use without forcing a
// full re-analyze.
if (!meta.remoteUrl && isGitRepo(repoPath)) {
meta.remoteUrl = getRemoteUrl(repoPath);
}
await registerRepo(repoPath, meta);
await addToGitignore(repoPath);

View file

@ -4,6 +4,9 @@
*/
import { execFileSync } from 'node:child_process';
import path from 'path';
import { readRegistry, type RegistryEntry, type CwdMatch } from '../storage/repo-manager.js';
import { getGitRoot, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
export interface StalenessInfo {
isStale: boolean;
@ -37,3 +40,111 @@ export function checkStaleness(repoPath: string, lastCommit: string): StalenessI
return { isStale: false, commitsBehind: 0 };
}
}
/**
* Compare a sibling-clone HEAD against an indexed `lastCommit`. Returns
* `undefined` when the indexed commit is not reachable from the sibling
* (e.g. divergent branches, shallow clone, missing ref). The caller
* should treat `undefined` as "drift unknown" rather than "no drift".
*/
function commitsAheadOfIndexed(siblingPath: string, indexedCommit: string): number | undefined {
if (!indexedCommit) return undefined;
try {
const result = execFileSync('git', ['rev-list', '--count', `${indexedCommit}..HEAD`], {
cwd: siblingPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
return parseInt(result, 10) || 0;
} catch {
return undefined;
}
}
/**
* Resolve a working directory against the global registry. Returns:
* - `match: 'path'` when `cwd` is inside a registered entry's path
* - `match: 'sibling-by-remote'` when `cwd` lives in a different on-disk clone
* of the same repo (same `remoteUrl`)
* - `match: 'none'` when neither match applies
*
* For sibling-by-remote matches, the caller's HEAD and the drift vs the
* indexed `lastCommit` are also returned so the MCP layer can warn
* before serving silently-stale answers (issue: silent graph drift
* across sibling clones).
*
* `path` matches deliberately use the longest-prefix rule so a cwd
* inside a sub-path of a registered repo still matches that repo, not
* a coincidentally-aliased shorter entry.
*/
export async function checkCwdMatch(cwd: string): Promise<CwdMatch> {
const entries = await readRegistry();
if (entries.length === 0) return { match: 'none' };
const isWin = process.platform === 'win32';
const norm = (p: string) => (isWin ? path.resolve(p).toLowerCase() : path.resolve(p));
const sep = path.sep;
const cwdResolved = path.resolve(cwd);
const cwdNorm = norm(cwdResolved);
// 1) Path-based match (longest prefix wins, boundary-safe).
let bestPath: RegistryEntry | undefined;
let bestLen = -1;
for (const e of entries) {
const p = norm(e.path);
if (cwdNorm === p || cwdNorm.startsWith(p + sep)) {
if (p.length > bestLen) {
bestPath = e;
bestLen = p.length;
}
}
}
if (bestPath) return { match: 'path', entry: bestPath };
// 2) Sibling-by-remote: locate the cwd's git root, get its remote
// URL, and look for any registered entry with the same fingerprint.
const cwdGitRoot = getGitRoot(cwdResolved);
if (!cwdGitRoot) return { match: 'none' };
const cwdRemote = getRemoteUrl(cwdGitRoot);
if (!cwdRemote) return { match: 'none' };
const sibling = entries.find(
(e) => e.remoteUrl === cwdRemote && norm(e.path) !== norm(cwdGitRoot),
);
if (!sibling) return { match: 'none' };
const cwdHead = getCurrentCommit(cwdGitRoot) || undefined;
const drift = commitsAheadOfIndexed(cwdGitRoot, sibling.lastCommit);
// Same commit on both clones → still report match=sibling-by-remote
// (the relationship is real and useful to callers like list_repos /
// future tooling) but leave `hint` unset: there's nothing to warn
// about, and `maybeWarnSiblingDrift` already short-circuits this
// case independently. Surfacing a no-op hint would force callers
// to second-guess whether they need to display it.
let hint: string | undefined;
if (cwdHead && cwdHead === sibling.lastCommit) {
hint = undefined;
} else if (drift && drift > 0) {
hint =
`⚠️ Index for "${sibling.name}" was built at ${sibling.path}; ` +
`your cwd (${cwdGitRoot}) is a sibling clone that is ${drift} commit${drift > 1 ? 's' : ''} ` +
`ahead of the indexed commit. Results may be stale or incorrect — re-run \`gitnexus analyze\` ` +
`to refresh the index.`;
} else {
hint =
`⚠️ Index for "${sibling.name}" was built at ${sibling.path}; ` +
`your cwd (${cwdGitRoot}) is a sibling clone whose HEAD differs from the indexed commit. ` +
`Results may be stale or incorrect — re-run \`gitnexus analyze\` to refresh the index.`;
}
return {
match: 'sibling-by-remote',
entry: sibling,
cwdGitRoot,
cwdHead,
drift,
hint,
};
}

View file

@ -89,10 +89,25 @@ export function parseGroupConfig(yamlContent: string): GroupConfig {
};
}
export class GroupNotFoundError extends Error {
constructor(public readonly groupName: string) {
super(`Group "${groupName}" not found`);
this.name = 'GroupNotFoundError';
}
}
export async function loadGroupConfig(groupDir: string): Promise<GroupConfig> {
const fsp = await import('node:fs/promises');
const path = await import('node:path');
const yamlPath = path.join(groupDir, 'group.yaml');
const content = await fsp.readFile(yamlPath, 'utf-8');
let content: string;
try {
content = await fsp.readFile(yamlPath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
throw new GroupNotFoundError(path.basename(groupDir));
}
throw err;
}
return parseGroupConfig(content);
}

View file

@ -15,7 +15,7 @@ import type {
OutOfScopeLink,
} from './types.js';
import type { GroupRepoHandle, GroupToolPort } from './service.js';
import { loadGroupConfig } from './config-parser.js';
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
import {
fileMatchesServicePrefix,
normalizeServicePrefix,
@ -329,6 +329,8 @@ export async function runGroupImpact(
try {
config = await loadGroupConfig(groupDir);
} catch (e) {
if (e instanceof GroupNotFoundError)
return { error: `Group "${name}" not found. Run group_list to see configured groups.` };
return { error: e instanceof Error ? e.message : String(e) };
}
@ -344,9 +346,6 @@ export async function runGroupImpact(
minConfidence,
};
// Single shared deadline for Phase 1 (local walk) + Phase 2 (bridge fan-out).
// Phase 1 still gets the full budget; Phase 2 only uses whatever wall-clock
// time is left, so total work cannot exceed `timeoutMs`.
const deadline = Date.now() + Math.max(0, timeoutMs);
const { value: local, timedOut: localTimedOut } = await safeLocalImpact(
@ -357,7 +356,7 @@ export async function runGroupImpact(
);
if (localTimedOut) {
const base = local as Record<string, unknown>;
const _base = local as Record<string, unknown>;
return {
local,
group: name,
@ -464,7 +463,6 @@ export async function runGroupImpact(
continue;
}
if (!repoInSubgroup(n.neighborRepo, subgroup)) {
// CrossLink convention: consumer -> provider
outOfScope.push({
from: direction === 'upstream' ? n.neighborRepo : repoPath,
to: direction === 'upstream' ? repoPath : n.neighborRepo,

View file

@ -6,7 +6,7 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import { checkStaleness } from '../git-staleness.js';
import { loadGroupConfig } from './config-parser.js';
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
import {
fileMatchesServicePrefix,
normalizeServicePrefix,
@ -221,7 +221,14 @@ export class GroupService {
return { groups };
}
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
let config: GroupConfig;
try {
config = await loadGroupConfig(groupDir);
} catch (err) {
if (err instanceof GroupNotFoundError)
return { error: `Group "${name}" not found. Run group_list to see configured groups.` };
throw err;
}
return {
name: config.name,
description: config.description,
@ -234,7 +241,14 @@ export class GroupService {
const name = String(params.name ?? '').trim();
if (!name) return { error: 'name is required' };
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
let config: GroupConfig;
try {
config = await loadGroupConfig(groupDir);
} catch (err) {
if (err instanceof GroupNotFoundError)
return { error: `Group "${name}" not found. Run group_list to see configured groups.` };
throw err;
}
const result = await syncGroup(config, {
groupDir,
exactOnly: Boolean(params.exactOnly),
@ -313,6 +327,14 @@ export class GroupService {
try {
config = await loadGroupConfig(groupDir);
} catch (e) {
if (e instanceof GroupNotFoundError)
return {
group: name,
target: target || uid,
service: servicePrefix,
error: `Group "${name}" not found. Run group_list to see configured groups.`,
results: [],
};
return {
group: name,
target: target || uid,
@ -326,9 +348,6 @@ export class GroupService {
repoInSubgroup(repoPath, subgroup, subgroupExact),
);
// Per-repo work is independent (each repo opens its own DB handle and the
// group-level result preserves repo iteration order via the indexed map).
// Errors are caught per repo so one slow/failed member does not block the rest.
const results: GroupContextResult['results'] = await Promise.all(
memberEntries.map(async ([repoPath, registryName]) => {
try {
@ -384,14 +403,19 @@ export class GroupService {
const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
const subgroupExact = params.subgroupExact === true;
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
let config: GroupConfig;
try {
config = await loadGroupConfig(groupDir);
} catch (err) {
if (err instanceof GroupNotFoundError)
return { error: `Group "${name}" not found. Run group_list to see configured groups.` };
throw err;
}
const memberEntries = Object.entries(config.repos).filter(([repoPath]) =>
repoInSubgroup(repoPath, subgroup, subgroupExact),
);
// Per-repo query is independent; run them concurrently and isolate
// failures so one slow/failed member does not block the rest.
const perRepo = await Promise.all(
memberEntries.map(async ([repoPath, registryName]) => {
try {
@ -436,7 +460,14 @@ export class GroupService {
const name = String(params.name ?? '').trim();
if (!name) return { error: 'name is required' };
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
let config: GroupConfig;
try {
config = await loadGroupConfig(groupDir);
} catch (err) {
if (err instanceof GroupNotFoundError)
return { error: `Group "${name}" not found. Run group_list to see configured groups.` };
throw err;
}
const registry = await readContractRegistry(groupDir);
const repoStatuses: Record<

View file

@ -29,7 +29,7 @@ import {
registerRepo,
cleanupOldKuzuFiles,
} from '../storage/repo-manager.js';
import { getCurrentCommit, hasGitDir, getInferredRepoName } from '../storage/git.js';
import { getCurrentCommit, getRemoteUrl, hasGitDir, getInferredRepoName } from '../storage/git.js';
import type { CachedEmbedding } from './embeddings/types.js';
import { generateAIContextFiles } from '../cli/ai-context.js';
import { EMBEDDING_TABLE_NAME } from './lbug/schema.js';
@ -318,6 +318,13 @@ export async function runFullAnalysis(
repoPath,
lastCommit: currentCommit,
indexedAt: new Date().toISOString(),
// Captured here (not at registration) so it travels with the
// on-disk meta.json — sibling-clone fingerprinting works for
// out-of-tree consumers (group-status, future tooling) without
// a second git shellout. `undefined` when the repo has no
// origin remote, which is fine: paths-only repos behave as
// before.
remoteUrl: hasGitDir(repoPath) ? getRemoteUrl(repoPath) : undefined,
stats: {
files: pipelineResult.totalFileCount,
nodes: stats.nodes,

View file

@ -32,6 +32,7 @@ import { resolveAtGroupMemberRepoPath } from '../../core/group/resolve-at-member
import { collectBestChunks } from '../../core/embeddings/types.js';
import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js';
import { PhaseTimer } from '../../core/search/phase-timer.js';
import { checkStaleness, checkCwdMatch } from '../../core/git-staleness.js';
// AI context generation is CLI-only (gitnexus analyze)
// import { generateAIContextFiles } from '../../cli/ai-context.js';
@ -198,6 +199,7 @@ interface RepoHandle {
lbugPath: string;
indexedAt: string;
lastCommit: string;
remoteUrl?: string;
stats?: RegistryEntry['stats'];
}
@ -208,6 +210,13 @@ export class LocalBackend {
private reinitPromises: Map<string, Promise<void>> = new Map();
private lastStalenessCheck: Map<string, number> = new Map();
private groupToolSvc: GroupService | null = null;
/**
* One-shot stderr warnings for sibling-clone drift, keyed by
* `${repoId}|${cwdGitRoot}`. Without this guard every tool call
* from inside a sibling clone would print the same warning,
* making MCP stderr unreadable.
*/
private warnedSiblingDrift: Set<string> = new Set();
/**
* Cross-repo group tools (CLI). Shares logic with MCP `group_*` handlers.
@ -275,6 +284,7 @@ export class LocalBackend {
lbugPath,
indexedAt: entry.indexedAt,
lastCommit: entry.lastCommit,
remoteUrl: entry.remoteUrl,
stats: entry.stats,
};
@ -333,12 +343,26 @@ export class LocalBackend {
*/
async resolveRepo(repoParam?: string): Promise<RepoHandle> {
const result = this.resolveRepoFromCache(repoParam);
if (result) return result;
if (result) {
// Issue: silent graph drift across sibling clones.
// If the caller's cwd lives in a *different* on-disk clone of
// the same repo (matched by `remoteUrl`), warn once per
// (repo, cwd) pair on stderr. We do not fail or refuse to
// serve — the index is still the best answer we have — but
// the operator/agent has to know the answer may be stale.
this.maybeWarnSiblingDrift(result).catch(() => {
/* best-effort; never throw from resolveRepo */
});
return result;
}
// Miss — refresh registry and try once more
await this.refreshRepos();
const retried = this.resolveRepoFromCache(repoParam);
if (retried) return retried;
if (retried) {
this.maybeWarnSiblingDrift(retried).catch(() => {});
return retried;
}
// Still no match — throw with helpful message
if (this.repos.size === 0) {
@ -476,18 +500,128 @@ export class LocalBackend {
* List all registered repos with their metadata.
* Re-reads the global registry so newly indexed repos are discovered
* without restarting the MCP server.
*
* Each entry includes:
* - `staleness`: if the indexed clone's own HEAD has moved past
* the recorded `lastCommit` (option D in the issue's fix list).
* - `siblings`: other registered entries sharing the same
* `remoteUrl` (option B's payoff: callers can see at a glance
* that another clone of the same logical repo is registered).
* - `remoteUrl`: the canonical origin URL recorded at index time.
*/
async listRepos(): Promise<
Array<{ name: string; path: string; indexedAt: string; lastCommit: string; stats?: any }>
Array<{
name: string;
path: string;
indexedAt: string;
lastCommit: string;
remoteUrl?: string;
stats?: any;
staleness?: { commitsBehind: number; hint?: string };
siblings?: Array<{ name: string; path: string; lastCommit: string }>;
}>
> {
await this.refreshRepos();
return [...this.repos.values()].map((h) => ({
name: h.name,
path: h.repoPath,
indexedAt: h.indexedAt,
lastCommit: h.lastCommit,
stats: h.stats,
}));
const handles = [...this.repos.values()];
// Pre-group registered handles by `remoteUrl` so the sibling
// lookup is O(1) per handle. We reuse the in-memory `this.repos`
// (already populated by `refreshRepos`) instead of doing a fresh
// `readRegistry()` per entry — that would be N file reads for N
// registered repos.
const isWin = process.platform === 'win32';
const norm = (p: string) => (isWin ? path.resolve(p).toLowerCase() : path.resolve(p));
const byRemote = new Map<string, RepoHandle[]>();
for (const h of handles) {
if (!h.remoteUrl) continue;
const list = byRemote.get(h.remoteUrl) ?? [];
list.push(h);
byRemote.set(h.remoteUrl, list);
}
return handles.map((h) => {
const stale = checkStaleness(h.repoPath, h.lastCommit);
const selfNorm = norm(h.repoPath);
const siblings = h.remoteUrl
? (byRemote.get(h.remoteUrl) ?? []).filter((e) => norm(e.repoPath) !== selfNorm)
: [];
return {
name: h.name,
path: h.repoPath,
indexedAt: h.indexedAt,
lastCommit: h.lastCommit,
remoteUrl: h.remoteUrl,
stats: h.stats,
staleness: stale.isStale
? { commitsBehind: stale.commitsBehind, hint: stale.hint }
: undefined,
siblings:
siblings.length > 0
? siblings.map((s) => ({
name: s.name,
path: s.repoPath,
lastCommit: s.lastCommit,
}))
: undefined,
};
});
}
/**
* Best-effort sibling-clone drift warning.
*
* When the resolved index has a `remoteUrl` recorded and the caller's
* `process.cwd()` is inside a *different* clone of the same repo, emit
* one stderr line per (repo, cwd) pair so the operator knows the
* graph may be stale relative to what's actually on disk under their
* cwd. Silent on path matches and on repos without a remote URL.
*
* Limitation: in MCP stdio server mode `process.cwd()` is the
* server's CWD at start time, *not* the agent client's CWD. The
* warning therefore only fires when the MCP server itself was
* launched from inside a sibling clone (typical for `npx gitnexus
* serve` from a polecat workspace). Surfacing the client's CWD
* would require a per-tool-call `cwd` parameter — out of scope for
* the current MCP contract.
*
* Pure side-effect (stderr); never affects the returned handle.
* After the first computation for a given (repo, cwd) pair the
* result is cached so subsequent `resolveRepo()` calls don't
* re-shell-out to git.
*/
private async maybeWarnSiblingDrift(handle: RepoHandle): Promise<void> {
if (!handle.remoteUrl) return;
let cwd: string;
try {
cwd = process.cwd();
} catch {
return;
}
// Early-exit cache: keyed on (repo, cwd) BEFORE any git shellout.
// After the first call for a given cwd, this short-circuits the
// up-to-four `execSync`/`execFileSync` calls inside `checkCwdMatch`
// — important for MCP-server mode where `process.cwd()` is constant
// and `resolveRepo` runs on every tool call.
const cacheKey = `${handle.id}|${cwd}`;
if (this.warnedSiblingDrift.has(cacheKey)) return;
const match = await checkCwdMatch(cwd);
if (
match.match !== 'sibling-by-remote' ||
!match.entry ||
!match.cwdGitRoot ||
match.entry.path !== handle.repoPath ||
!match.hint
) {
// Cache "nothing to warn about" outcomes too — `checkCwdMatch`
// is deterministic for a fixed (registry, cwd) pair, so re-running
// it yields nothing new.
this.warnedSiblingDrift.add(cacheKey);
return;
}
this.warnedSiblingDrift.add(cacheKey);
console.error(`GitNexus: ${match.hint}`);
}
// ─── Tool Dispatch ───────────────────────────────────────────────

View file

@ -21,6 +21,66 @@ export const getCurrentCommit = (repoPath: string): string => {
}
};
/**
* Get a stable canonical identifier for the repo's `origin` remote, if any.
*
* Used to fingerprint two on-disk clones as the same logical repository
* (issue #XXX — silent graph drift across sibling clones). `path` alone
* is unreliable: worktrees, "clean clone for indexing" hygiene, and
* multi-agent workspaces routinely have the same repo at multiple
* absolute paths. The remote URL is the only on-disk signal that
* survives those conventions.
*
* Normalisation strategy:
* - Strip a trailing `.git` so `https://x/y` and `https://x/y.git` collapse.
* - Strip a trailing `/` for the same reason.
* - `git@github.com:foo/bar` and `https://github.com/foo/bar` are
* intentionally NOT collapsed — they are different remotes from
* git's perspective and we don't want to assert equivalence.
* - Lower-case the host portion so `GitHub.com` and `github.com`
* don't desync; preserves case in path because some hosts
* (Bitbucket Server) treat repo paths case-sensitively.
*
* Returns `undefined` when there is no origin remote, the directory
* isn't a git repo, or git itself isn't available.
*/
export const getRemoteUrl = (repoPath: string): string | undefined => {
let raw: string;
try {
raw = execSync('git config --get remote.origin.url', {
cwd: repoPath,
stdio: ['ignore', 'pipe', 'ignore'],
})
.toString()
.trim();
} catch {
return undefined;
}
if (!raw) return undefined;
let normalised = raw.replace(/\/$/, '').replace(/\.git$/, '');
// Lower-case the host segment of `scheme://[user@]host[:port]/...`
// and the host segment of `git@host:owner/repo` SCP form.
// SSH user-segment regex deliberately accepts the common
// `git@`/`<alnum>-_@` cases. Less common usernames (e.g. with
// dots) fall through to the URL-form branch — they will simply
// not get host-case normalisation, which is acceptable: the raw
// `git config` output is still a valid fingerprint, just slightly
// less collapsible across host casings.
const sshMatch = normalised.match(/^(git@|[a-zA-Z0-9_-]+@)([^:/]+)(:.+)$/);
if (sshMatch) {
normalised = `${sshMatch[1]}${sshMatch[2].toLowerCase()}${sshMatch[3]}`;
} else {
const urlMatch = normalised.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/]+)(\/.*)?$/);
if (urlMatch) {
normalised = `${urlMatch[1]}${urlMatch[2].toLowerCase()}${urlMatch[3] ?? ''}`;
}
}
return normalised;
};
/**
* Find the git repository root from any path inside the repo
*/

View file

@ -55,6 +55,14 @@ export interface RepoMeta {
repoPath: string;
lastCommit: string;
indexedAt: string;
/**
* Canonical `origin` remote URL captured at index time. Used to
* fingerprint the same logical repo across multiple on-disk clones
* (worktrees, agent workspaces, "clean clone for indexing"). When
* absent (no remote configured, git unavailable, etc.) the repo is
* treated as path-only and sibling-clone detection is skipped.
*/
remoteUrl?: string;
stats?: {
files?: number;
nodes?: number;
@ -82,6 +90,8 @@ export interface RegistryEntry {
storagePath: string;
indexedAt: string;
lastCommit: string;
/** See {@link RepoMeta.remoteUrl}. Mirrored from meta at register time. */
remoteUrl?: string;
stats?: RepoMeta['stats'];
}
@ -469,6 +479,7 @@ export const registerRepo = async (
storagePath,
indexedAt: meta.indexedAt,
lastCommit: meta.lastCommit,
remoteUrl: meta.remoteUrl,
stats: meta.stats,
};
@ -764,3 +775,69 @@ export const saveCLIConfig = async (config: CLIConfig): Promise<void> => {
}
}
};
// ─── Sibling-clone detection ─────────────────────────────────────────────
//
// A "sibling clone" is a different on-disk path that points at the same
// logical repository (same `origin` remote URL) as a registered index.
// This shows up in three operationally important shapes (see issue):
//
// 1. The same repo is checked out under multiple paths (worktrees,
// multi-agent workspaces). Only one is indexed; the others silently
// diverge from the graph.
// 2. The indexed clone is itself behind its own HEAD (the existing
// `checkStaleness` already handles this case).
// 3. A query is issued from a `cwd` that lives inside a sibling clone
// whose HEAD has drifted from the indexed `lastCommit`.
//
// Detection is intentionally remote-URL-based and does NOT walk the
// filesystem hunting for unregistered clones — only registered entries
// are considered. The `cwd`-driven branch ({@link checkSiblingDrift})
// also accepts an unregistered cwd, because the live caller's working
// directory is the one place we can cheaply learn about an
// unregistered clone.
/**
* Find other registered entries whose `remoteUrl` matches the given
* one, excluding `selfPath` (case-insensitive on Windows). Entries
* without a `remoteUrl` are ignored — we cannot prove sibling-ness
* without a fingerprint.
*/
export const findSiblingClones = async (
remoteUrl: string | undefined,
selfPath: string,
): Promise<RegistryEntry[]> => {
if (!remoteUrl) return [];
const entries = await readRegistry();
const isWin = process.platform === 'win32';
const norm = (p: string) => (isWin ? path.resolve(p).toLowerCase() : path.resolve(p));
const self = norm(selfPath);
return entries.filter((e) => e.remoteUrl === remoteUrl && norm(e.path) !== self);
};
/**
* Description of how a working directory relates to a registered index.
*
* `match` semantics:
* - `path` — `cwd` is inside the registered entry's path.
* - `sibling-by-remote` — `cwd` is in a different on-disk clone of the
* same repo (same `remoteUrl`).
* - `none` — no relationship found.
*/
export interface CwdMatch {
match: 'path' | 'sibling-by-remote' | 'none';
entry?: RegistryEntry;
/** The git toplevel of `cwd`, when `cwd` is inside a git work tree. */
cwdGitRoot?: string;
/** HEAD of the cwd's clone, when resolvable. */
cwdHead?: string;
/**
* Number of commits the registered `lastCommit` is behind the
* sibling-clone HEAD, when both refs are known to the cwd's clone.
* `undefined` when the comparison cannot be performed (e.g. the
* indexed commit isn't reachable from cwd).
*/
drift?: number;
/** Human-readable hint, set whenever the situation warrants warning. */
hint?: string;
}

View file

@ -0,0 +1,17 @@
namespace Demo;
public class Greeter
{
public string Greet(string name) => $"Hello, {name}!";
public static void Main(string[] args)
{
var g = new Greeter();
System.Console.WriteLine(g.Greet("world"));
}
}
public interface IFoo
{
void Bar();
}

View file

@ -17,6 +17,7 @@ import { API_IMPACT_SEED_DATA, API_IMPACT_FTS_INDEXES } from '../fixtures/api-im
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
withTestLbugDB(

View file

@ -21,6 +21,7 @@ import { withTestLbugDB } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// ─── Seed builders ───────────────────────────────────────────────────────────

View file

@ -17,6 +17,7 @@ import { withTestLbugDB } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// Mirrors the exact graph structure from issue #480:

View file

@ -17,6 +17,7 @@ import {
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// ─── Block 2: callTool dispatch tests ────────────────────────────────

View file

@ -124,10 +124,10 @@ describe('C# ambiguous symbol resolution', () => {
// The key invariant: no edge points to Other/
if (extends_[0].targetFilePath) {
expect(extends_[0].targetFilePath).not.toMatch(/Other\//);
expect(extends_[0].targetFilePath).not.toContain('Other/');
}
if (implements_[0].targetFilePath) {
expect(implements_[0].targetFilePath).not.toMatch(/Other\//);
expect(implements_[0].targetFilePath).not.toContain('Other/');
}
});
});
@ -2048,3 +2048,77 @@ describe('C# interface-to-interface heritage', () => {
expect(implements_.length).toBe(4);
});
});
// ---------------------------------------------------------------------------
// C# parse completeness regression (#903)
// ---------------------------------------------------------------------------
describe('C# parse completeness (#903 regression)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-hello'), () => {});
}, 60000);
it('parse phase completes without error (no crash)', () => {
expect(result).toBeDefined();
expect(result.graph).toBeDefined();
});
it('emits Class node for Greeter', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('Greeter');
});
it('emits Interface node for IFoo', () => {
const interfaces = getNodesByLabel(result, 'Interface');
expect(interfaces).toContain('IFoo');
});
it('emits Method nodes for Greet, Main, and Bar', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('Greet');
expect(methods).toContain('Main');
expect(methods).toContain('Bar');
});
it('Greet has parameterCount=1 and returnType=string', () => {
const methods = getNodesByLabelFull(result, 'Method');
const greet = methods.find((m) => m.name === 'Greet');
expect(greet).toBeDefined();
expect(greet!.properties.parameterCount).toBe(1);
expect(greet!.properties.returnType).toBe('string');
expect(greet!.properties.visibility).toBe('public');
});
it('Main has parameterCount=1 and isStatic=true', () => {
const methods = getNodesByLabelFull(result, 'Method');
const main = methods.find((m) => m.name === 'Main');
expect(main).toBeDefined();
expect(main!.properties.parameterCount).toBe(1);
expect(main!.properties.isStatic).toBe(true);
expect(main!.properties.visibility).toBe('public');
});
it('Bar is abstract with parameterCount=0 and returnType=void', () => {
const methods = getNodesByLabelFull(result, 'Method');
const bar = methods.find((m) => m.name === 'Bar');
expect(bar).toBeDefined();
expect(bar!.properties.parameterCount).toBe(0);
expect(bar!.properties.isAbstract).toBe(true);
expect(bar!.properties.returnType).toBe('void');
});
it('emits HAS_METHOD edges linking Greeter to its methods', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const targets = edgeSet(hasMethod);
expect(targets).toContain('Greeter → Greet');
expect(targets).toContain('Greeter → Main');
});
it('emits HAS_METHOD edge linking IFoo to Bar', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const targets = edgeSet(hasMethod);
expect(targets).toContain('IFoo → Bar');
});
});

View file

@ -16,6 +16,7 @@ import { withTestLbugDB } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// ─── Seed data ────────────────────────────────────────────────────────────────

View file

@ -30,6 +30,7 @@ import { vi } from 'vitest';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
withTestLbugDB(

View file

@ -37,6 +37,15 @@ vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => {
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// `core/git-staleness` is also imported by `local-backend.ts` (for
// `checkStaleness` and `checkCwdMatch`). Stub it out here so unit
// tests don't shell out to git.
vi.mock('../../src/core/git-staleness.js', () => ({
checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }),
checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }),
}));
// Also mock the search modules to avoid loading onnxruntime
@ -748,6 +757,45 @@ describe('LocalBackend.resolveRepo', () => {
// listRegisteredRepos should have been called again
expect(listRegisteredRepos).toHaveBeenCalledTimes(2); // once in init, once in refreshRepos
});
it('emits sibling-clone drift warning exactly once per (repo, cwd) pair', async () => {
// Regression guard for the one-shot stderr warning emitted when
// the caller's cwd is in a sibling clone of the resolved index.
// The cache must short-circuit BOTH `console.error` and the
// underlying `checkCwdMatch` git shellouts on subsequent calls.
const { checkCwdMatch } = await import('../../src/core/git-staleness.js');
(listRegisteredRepos as any).mockResolvedValue([
{ ...MOCK_REPO_ENTRY, remoteUrl: 'https://example.com/foo/bar' },
]);
(checkCwdMatch as any).mockResolvedValue({
match: 'sibling-by-remote',
entry: { ...MOCK_REPO_ENTRY, remoteUrl: 'https://example.com/foo/bar' },
cwdGitRoot: '/tmp/sibling-clone',
cwdHead: 'feedface',
hint: '⚠️ stale sibling clone',
});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await backend.init();
// Three resolveRepo invocations from the same cwd:
await backend.callTool('list_repos', {}); // resolveRepo not called for list_repos
// Use a real resolveRepo path:
await backend.resolveRepo();
await backend.resolveRepo();
await backend.resolveRepo();
const drift = errSpy.mock.calls.filter((c) => String(c[0]).includes('stale sibling clone'));
expect(drift).toHaveLength(1);
// checkCwdMatch should also only run once — the cache check
// happens BEFORE the shellout-heavy match call.
expect(checkCwdMatch).toHaveBeenCalledTimes(1);
} finally {
errSpy.mockRestore();
(checkCwdMatch as any).mockResolvedValue({ match: 'none' });
}
});
});
// ─── getContext ──────────────────────────────────────────────────────

View file

@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest';
import path from 'path';
import os from 'os';
import fs from 'fs';
import { execSync } from 'child_process';
// ─── hasGitDir ────────────────────────────────────────────────────────────
//
@ -111,3 +112,71 @@ describe('getGitRoot', () => {
}
});
});
// ─── getRemoteUrl ─────────────────────────────────────────────────────────
describe('getRemoteUrl', () => {
const setupRepoWithRemote = (remoteUrl: string): string => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-remote-'));
// Use real fs paths and shellouts — the helper itself shells out to
// `git config`, so we need a real git repo for the assertion to be
// meaningful.
execSync('git init -q', { cwd: tmpDir });
execSync(`git remote add origin ${remoteUrl}`, { cwd: tmpDir });
return tmpDir;
};
it('returns undefined for a non-git directory', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
try {
expect(getRemoteUrl(tmpDir)).toBeUndefined();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns undefined for a git repo with no origin remote', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
try {
execSync('git init -q', { cwd: tmpDir });
expect(getRemoteUrl(tmpDir)).toBeUndefined();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('strips trailing .git and lowercases host for HTTPS remotes', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = setupRepoWithRemote('https://GitHub.COM/Foo/Bar.git');
try {
expect(getRemoteUrl(tmpDir)).toBe('https://github.com/Foo/Bar');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('lowercases host for SCP-style SSH remotes and strips .git', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = setupRepoWithRemote('git@GitHub.com:Foo/Bar.git');
try {
expect(getRemoteUrl(tmpDir)).toBe('git@github.com:Foo/Bar');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns the same fingerprint for two clones of the same repo', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const a = setupRepoWithRemote('https://example.com/foo/bar.git');
const b = setupRepoWithRemote('https://example.com/foo/bar');
try {
expect(getRemoteUrl(a)).toBe(getRemoteUrl(b));
expect(getRemoteUrl(a)).toBeTruthy();
} finally {
fs.rmSync(a, { recursive: true, force: true });
fs.rmSync(b, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const loadGroupConfigMock = vi.fn();
const getGroupDirMock = vi.fn(() => '/fake/.gitnexus/groups/missing');
const getDefaultGitnexusDirMock = vi.fn(() => '/fake/.gitnexus');
const readContractRegistryMock = vi.fn(() => null);
const listGroupsMock = vi.fn(() => []);
const syncGroupMock = vi.fn();
vi.mock('../../src/core/group/config-parser.js', async () => {
const { GroupNotFoundError } = await vi.importActual<
typeof import('../../src/core/group/config-parser.js')
>('../../src/core/group/config-parser.js');
return { loadGroupConfig: loadGroupConfigMock, GroupNotFoundError };
});
vi.mock('../../src/core/group/storage.js', () => ({
getDefaultGitnexusDir: getDefaultGitnexusDirMock,
getGroupDir: getGroupDirMock,
readContractRegistry: readContractRegistryMock,
listGroups: listGroupsMock,
}));
vi.mock('../../src/core/group/sync.js', () => ({ syncGroup: syncGroupMock }));
vi.mock('../../src/core/git-staleness.js', () => ({ checkStaleness: vi.fn() }));
describe('GroupService — missing group error handling', () => {
let GroupService: typeof import('../../src/core/group/service.js').GroupService;
let GroupNotFoundError: typeof import('../../src/core/group/config-parser.js').GroupNotFoundError;
let service: InstanceType<typeof import('../../src/core/group/service.js').GroupService>;
const stubPort = {
resolveRepo: vi.fn(),
impact: vi.fn(),
query: vi.fn(),
impactByUid: vi.fn(),
contextByUid: vi.fn(),
};
beforeEach(async () => {
vi.resetModules();
loadGroupConfigMock.mockReset();
({ GroupService } = await import('../../src/core/group/service.js'));
({ GroupNotFoundError } = await import('../../src/core/group/config-parser.js'));
service = new GroupService(stubPort as never);
loadGroupConfigMock.mockRejectedValue(new GroupNotFoundError('missing'));
});
it('groupSync returns friendly error for missing group', async () => {
const result = await service.groupSync({ name: 'missing' });
expect(result).toEqual({
error: 'Group "missing" not found. Run group_list to see configured groups.',
});
});
it('groupQuery returns friendly error for missing group', async () => {
const result = await service.groupQuery({ name: 'missing', query: 'auth' });
expect(result).toEqual({
error: 'Group "missing" not found. Run group_list to see configured groups.',
});
});
it('groupStatus returns friendly error for missing group', async () => {
const result = await service.groupStatus({ name: 'missing' });
expect(result).toEqual({
error: 'Group "missing" not found. Run group_list to see configured groups.',
});
});
it('groupSync re-throws non-ENOENT errors', async () => {
loadGroupConfigMock.mockRejectedValue(new Error('YAML parse error'));
await expect(service.groupSync({ name: 'bad-yaml' })).rejects.toThrow('YAML parse error');
});
it('groupQuery re-throws non-ENOENT errors', async () => {
loadGroupConfigMock.mockRejectedValue(new Error('YAML parse error'));
await expect(service.groupQuery({ name: 'bad-yaml', query: 'auth' })).rejects.toThrow(
'YAML parse error',
);
});
it('groupStatus re-throws non-ENOENT errors', async () => {
loadGroupConfigMock.mockRejectedValue(new Error('YAML parse error'));
await expect(service.groupStatus({ name: 'bad-yaml' })).rejects.toThrow('YAML parse error');
});
it('groupList returns friendly error for missing group', async () => {
const result = await service.groupList({ name: 'missing' });
expect(result).toEqual({
error: 'Group "missing" not found. Run group_list to see configured groups.',
});
});
});

View file

@ -25,6 +25,11 @@ vi.mock('../../src/storage/repo-manager.js', () => ({
vi.mock('../../src/storage/git.js', () => ({
getGitRoot: mockGetGitRoot,
isGitRepo: mockIsGitRepo,
// `index-repo.ts` calls `getRemoteUrl` to backfill `remoteUrl` on
// older `.gitnexus/meta.json` files. The unit tests don't care
// about the remote URL, so a static `undefined` keeps behaviour
// identical to the pre-feature path.
getRemoteUrl: vi.fn().mockReturnValue(undefined),
}));
describe('indexCommand', () => {

View file

@ -0,0 +1,308 @@
/**
* Unit tests: sibling-clone drift detection.
*
* Issue: a single absolute `repoPath` per registry entry causes silent
* graph drift when the same logical repo lives at multiple on-disk
* paths (worktrees, multi-agent workspaces, etc.). We persist a
* canonical `remoteUrl` at index time and use it to:
* - find sibling clones registered under different paths
* - detect when the caller's `cwd` is in a sibling clone whose HEAD
* has drifted from the indexed `lastCommit`
*
* These tests cover the persistence + helpers; the LocalBackend
* stderr-warning side-effect is exercised end-to-end via the same
* `checkCwdMatch` API.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import path from 'path';
import { execSync } from 'child_process';
import {
registerRepo,
readRegistry,
findSiblingClones,
type RepoMeta,
} from '../../src/storage/repo-manager.js';
import { checkCwdMatch } from '../../src/core/git-staleness.js';
import { createTempDir } from '../helpers/test-db.js';
const initRepoWithCommit = (dir: string, remoteUrl?: string): string => {
execSync('git init -q', { cwd: dir });
execSync('git config user.email test@example.com', { cwd: dir });
execSync('git config user.name test', { cwd: dir });
execSync('git commit --allow-empty -q -m initial', { cwd: dir });
if (remoteUrl) execSync(`git remote add origin ${remoteUrl}`, { cwd: dir });
return execSync('git rev-parse HEAD', { cwd: dir }).toString().trim();
};
describe('registry persists remoteUrl', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
let savedHome: string | undefined;
beforeEach(async () => {
tmpHome = await createTempDir('gitnexus-sibling-home-');
tmpRepo = await createTempDir('gitnexus-sibling-repo-');
savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
});
afterEach(async () => {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpHome.cleanup();
await tmpRepo.cleanup();
});
it('round-trips remoteUrl from RepoMeta into the registry', async () => {
const meta: RepoMeta = {
repoPath: tmpRepo.dbPath,
lastCommit: 'abc123',
indexedAt: new Date().toISOString(),
remoteUrl: 'https://example.com/foo/bar',
};
await registerRepo(tmpRepo.dbPath, meta);
const entries = await readRegistry();
expect(entries).toHaveLength(1);
expect(entries[0].remoteUrl).toBe('https://example.com/foo/bar');
});
it('omits remoteUrl from registry when meta has none (back-compat)', async () => {
const meta: RepoMeta = {
repoPath: tmpRepo.dbPath,
lastCommit: 'abc123',
indexedAt: new Date().toISOString(),
};
await registerRepo(tmpRepo.dbPath, meta);
const entries = await readRegistry();
expect(entries[0].remoteUrl).toBeUndefined();
});
});
describe('findSiblingClones', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let savedHome: string | undefined;
beforeEach(async () => {
tmpHome = await createTempDir('gitnexus-sibling-find-home-');
savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
});
afterEach(async () => {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpHome.cleanup();
});
it('returns other registered entries with the same remoteUrl', async () => {
const a = await createTempDir('clone-a-');
const b = await createTempDir('clone-b-');
const c = await createTempDir('clone-c-');
try {
const remote = 'https://example.com/foo/bar';
const baseMeta = {
lastCommit: 'x',
indexedAt: new Date().toISOString(),
};
await registerRepo(a.dbPath, { ...baseMeta, repoPath: a.dbPath, remoteUrl: remote });
await registerRepo(b.dbPath, { ...baseMeta, repoPath: b.dbPath, remoteUrl: remote });
await registerRepo(c.dbPath, {
...baseMeta,
repoPath: c.dbPath,
remoteUrl: 'https://example.com/other/repo',
});
const siblings = await findSiblingClones(remote, a.dbPath);
expect(siblings.map((s) => s.path).sort()).toEqual([path.resolve(b.dbPath)]);
} finally {
await a.cleanup();
await b.cleanup();
await c.cleanup();
}
});
it('returns [] when remoteUrl is undefined (no fingerprint to match)', async () => {
const a = await createTempDir('clone-a-');
try {
await registerRepo(a.dbPath, {
repoPath: a.dbPath,
lastCommit: 'x',
indexedAt: new Date().toISOString(),
});
const siblings = await findSiblingClones(undefined, a.dbPath);
expect(siblings).toEqual([]);
} finally {
await a.cleanup();
}
});
});
describe('checkCwdMatch', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let savedHome: string | undefined;
beforeEach(async () => {
tmpHome = await createTempDir('gitnexus-cwd-match-home-');
savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
});
afterEach(async () => {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpHome.cleanup();
});
it('returns match=path when cwd is inside the registered entry', async () => {
const repo = await createTempDir('cwd-repo-');
try {
const head = initRepoWithCommit(repo.dbPath, 'https://example.com/foo/bar');
await registerRepo(repo.dbPath, {
repoPath: repo.dbPath,
lastCommit: head,
indexedAt: new Date().toISOString(),
remoteUrl: 'https://example.com/foo/bar',
});
const m = await checkCwdMatch(repo.dbPath);
expect(m.match).toBe('path');
expect(m.entry?.path).toBe(path.resolve(repo.dbPath));
} finally {
await repo.cleanup();
}
});
it('detects sibling-by-remote when sibling HEAD differs from indexed commit', async () => {
const indexed = await createTempDir('cwd-indexed-');
const sibling = await createTempDir('cwd-sibling-');
try {
const remote = 'https://example.com/foo/bar';
const indexedHead = initRepoWithCommit(indexed.dbPath, remote);
// Sibling is a separate `git init` with the same remote URL —
// that's enough for the remote-URL-based fingerprint to match.
// Use a distinct commit message so the sibling's SHA cannot
// coincidentally collide with the indexed one even when both
// commits land in the same second.
execSync('git init -q', { cwd: sibling.dbPath });
execSync('git config user.email test@example.com', { cwd: sibling.dbPath });
execSync('git config user.name test', { cwd: sibling.dbPath });
execSync('git commit --allow-empty -q -m sibling-distinct', { cwd: sibling.dbPath });
execSync(`git remote add origin ${remote}`, { cwd: sibling.dbPath });
await registerRepo(indexed.dbPath, {
repoPath: indexed.dbPath,
lastCommit: indexedHead,
indexedAt: new Date().toISOString(),
remoteUrl: remote,
});
const m = await checkCwdMatch(sibling.dbPath);
expect(m.match).toBe('sibling-by-remote');
expect(m.entry?.path).toBe(path.resolve(indexed.dbPath));
// Path format differs between git and Node.js on Windows (8.3 short
// vs long names from os.tmpdir()). Verify the git root was resolved
// and it's not the indexed repo (it's the sibling clone's root).
expect(m.cwdGitRoot).toBeTruthy();
expect(m.cwdGitRoot).not.toBe(path.resolve(indexed.dbPath));
expect(m.hint).toBeTruthy();
} finally {
await indexed.cleanup();
await sibling.cleanup();
}
});
it('returns match=none when cwd is unrelated to any registered repo', async () => {
const indexed = await createTempDir('cwd-none-indexed-');
const stranger = await createTempDir('cwd-none-stranger-');
try {
const indexedHead = initRepoWithCommit(indexed.dbPath, 'https://example.com/foo/bar');
initRepoWithCommit(stranger.dbPath, 'https://example.com/totally/different');
await registerRepo(indexed.dbPath, {
repoPath: indexed.dbPath,
lastCommit: indexedHead,
indexedAt: new Date().toISOString(),
remoteUrl: 'https://example.com/foo/bar',
});
const m = await checkCwdMatch(stranger.dbPath);
expect(m.match).toBe('none');
} finally {
await indexed.cleanup();
await stranger.cleanup();
}
});
it('reports sibling-by-remote with a stale hint when cwd HEAD has advanced', async () => {
// Polecat-style scenario from the issue: index at path A, query
// from cwd=path B (same repo), get a warning rather than
// silently-stale data. We can't easily share commits between two
// separate temp `git init` repos, so we instead verify that the
// cwd HEAD is captured and the hint mentions either drift or a
// HEAD mismatch.
const indexed = await createTempDir('cwd-stale-indexed-');
const sibling = await createTempDir('cwd-stale-sibling-');
try {
const remote = 'https://example.com/foo/bar';
initRepoWithCommit(indexed.dbPath, remote);
// Use a fabricated indexed commit that doesn't exist in the
// sibling clone — git rev-list will fail and `drift` is left
// undefined. The hint must still flag this as a stale-or-divergent
// sibling clone. Named to make test intent obvious; not git's
// all-zero "null" OID, which has special semantics in some git
// commands.
const FAKE_INDEXED_COMMIT = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
initRepoWithCommit(sibling.dbPath, remote);
await registerRepo(indexed.dbPath, {
repoPath: indexed.dbPath,
lastCommit: FAKE_INDEXED_COMMIT,
indexedAt: new Date().toISOString(),
remoteUrl: remote,
});
const m = await checkCwdMatch(sibling.dbPath);
expect(m.match).toBe('sibling-by-remote');
expect(m.cwdHead).toBeTruthy();
expect(m.cwdHead).not.toBe(FAKE_INDEXED_COMMIT);
expect(m.hint).toMatch(/sibling clone/);
} finally {
await indexed.cleanup();
await sibling.cleanup();
}
});
it('omits hint when sibling cwd HEAD matches the indexed commit (no drift)', async () => {
// Same-commit sibling: the relationship is real (and surfaces in
// `match: 'sibling-by-remote'`) but there is nothing to warn
// about. `LocalBackend.maybeWarnSiblingDrift` short-circuits in
// exactly this case, so confirming `hint` is unset here pins the
// contract those two pieces of code rely on.
const indexed = await createTempDir('cwd-same-indexed-');
const sibling = await createTempDir('cwd-same-sibling-');
try {
const remote = 'https://example.com/foo/bar';
initRepoWithCommit(indexed.dbPath, remote);
const siblingHead = initRepoWithCommit(sibling.dbPath, remote);
// Register the indexed entry with the SIBLING's HEAD as
// `lastCommit`. That is the on-disk reality when both clones
// happen to be at the same commit hash — e.g. immediately
// after both fast-forwarded to the same `main`.
await registerRepo(indexed.dbPath, {
repoPath: indexed.dbPath,
lastCommit: siblingHead,
indexedAt: new Date().toISOString(),
remoteUrl: remote,
});
const m = await checkCwdMatch(sibling.dbPath);
expect(m.match).toBe('sibling-by-remote');
expect(m.cwdHead).toBe(siblingHead);
expect(m.hint).toBeUndefined();
} finally {
await indexed.cleanup();
await sibling.cleanup();
}
});
});