diff --git a/.dockerignore b/.dockerignore index e09631a0a..4a2866b8e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,6 +14,8 @@ coverage .env.local .env.*.local +**/*.tsbuildinfo + .gitnexus gitnexus-web/playwright-report gitnexus-web/test-results diff --git a/Dockerfile.cli b/Dockerfile.cli index d1d4f45a4..c45292e06 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -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 diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 14d9cb2c8..5166377ca 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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" diff --git a/gitnexus/package.json b/gitnexus/package.json index f0605ed61..da5f6c925 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -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", diff --git a/gitnexus/src/cli/index-repo.ts b/gitnexus/src/cli/index-repo.ts index 62138b4be..b909a40b5 100644 --- a/gitnexus/src/cli/index-repo.ts +++ b/gitnexus/src/cli/index-repo.ts @@ -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); diff --git a/gitnexus/src/core/git-staleness.ts b/gitnexus/src/core/git-staleness.ts index 2ef8f9c75..93e556ab5 100644 --- a/gitnexus/src/core/git-staleness.ts +++ b/gitnexus/src/core/git-staleness.ts @@ -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 { + 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, + }; +} diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index edaeeeec2..bd803981c 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -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 { 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); } diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index a80336b26..f8625cdc5 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -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; + const _base = local as Record; 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, diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index afbb66e0e..a412ceaa8 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -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< diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index e61c20f21..00e0574ac 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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, diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 3414f5f5b..4a1e3c41c 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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> = new Map(); private lastStalenessCheck: Map = 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 = 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 { 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(); + 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 { + 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 ─────────────────────────────────────────────── diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index c8d05ac4b..8e0d6e555 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -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@`/`-_@` 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 */ diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 8155e592f..5c592d570 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -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 => { } } }; + +// ─── 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 => { + 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; +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-hello/Hello.cs b/gitnexus/test/fixtures/lang-resolution/csharp-hello/Hello.cs new file mode 100644 index 000000000..ce5bdd541 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-hello/Hello.cs @@ -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(); +} diff --git a/gitnexus/test/integration/api-impact-e2e.test.ts b/gitnexus/test/integration/api-impact-e2e.test.ts index 04fcadd50..dea7bd9be 100644 --- a/gitnexus/test/integration/api-impact-e2e.test.ts +++ b/gitnexus/test/integration/api-impact-e2e.test.ts @@ -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( diff --git a/gitnexus/test/integration/class-impact-all-languages.test.ts b/gitnexus/test/integration/class-impact-all-languages.test.ts index 638eb0d6f..422dbb248 100644 --- a/gitnexus/test/integration/class-impact-all-languages.test.ts +++ b/gitnexus/test/integration/class-impact-all-languages.test.ts @@ -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 ─────────────────────────────────────────────────────────── diff --git a/gitnexus/test/integration/java-class-impact.test.ts b/gitnexus/test/integration/java-class-impact.test.ts index 3055695f6..031d32cd3 100644 --- a/gitnexus/test/integration/java-class-impact.test.ts +++ b/gitnexus/test/integration/java-class-impact.test.ts @@ -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: diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index b32aad270..27e6550cc 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -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 ──────────────────────────────── diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index 83ffd3585..31c49378c 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -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'); + }); +}); diff --git a/gitnexus/test/integration/shape-check-regression.test.ts b/gitnexus/test/integration/shape-check-regression.test.ts index e786498e2..ba53334d7 100644 --- a/gitnexus/test/integration/shape-check-regression.test.ts +++ b/gitnexus/test/integration/shape-check-regression.test.ts @@ -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 ──────────────────────────────────────────────────────────────── diff --git a/gitnexus/test/integration/staleness-and-stability.test.ts b/gitnexus/test/integration/staleness-and-stability.test.ts index b51594a03..308753781 100644 --- a/gitnexus/test/integration/staleness-and-stability.test.ts +++ b/gitnexus/test/integration/staleness-and-stability.test.ts @@ -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( diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 9a90b7030..d57bd9051 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -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 ────────────────────────────────────────────────────── diff --git a/gitnexus/test/unit/git-utils.test.ts b/gitnexus/test/unit/git-utils.test.ts index 1864ff4c1..d1fc187c4 100644 --- a/gitnexus/test/unit/git-utils.test.ts +++ b/gitnexus/test/unit/git-utils.test.ts @@ -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 }); + } + }); +}); diff --git a/gitnexus/test/unit/group-service-not-found.test.ts b/gitnexus/test/unit/group-service-not-found.test.ts new file mode 100644 index 000000000..b16f9074f --- /dev/null +++ b/gitnexus/test/unit/group-service-not-found.test.ts @@ -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; + + 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.', + }); + }); +}); diff --git a/gitnexus/test/unit/index-repo-command.test.ts b/gitnexus/test/unit/index-repo-command.test.ts index 2c2e19b3c..3f7a57153 100644 --- a/gitnexus/test/unit/index-repo-command.test.ts +++ b/gitnexus/test/unit/index-repo-command.test.ts @@ -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', () => { diff --git a/gitnexus/test/unit/sibling-clone-drift.test.ts b/gitnexus/test/unit/sibling-clone-drift.test.ts new file mode 100644 index 000000000..cd063ceec --- /dev/null +++ b/gitnexus/test/unit/sibling-clone-drift.test.ts @@ -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>; + let tmpRepo: Awaited>; + 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>; + 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>; + 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(); + } + }); +});