mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
Merge remote-tracking branch 'origin' into feat/csharp-scope-resolution
This commit is contained in:
commit
70691378ab
26 changed files with 1237 additions and 170 deletions
|
|
@ -14,6 +14,8 @@ coverage
|
|||
.env.local
|
||||
.env.*.local
|
||||
|
||||
**/*.tsbuildinfo
|
||||
|
||||
.gitnexus
|
||||
gitnexus-web/playwright-report
|
||||
gitnexus-web/test-results
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
272
gitnexus/package-lock.json
generated
272
gitnexus/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
17
gitnexus/test/fixtures/lang-resolution/csharp-hello/Hello.cs
vendored
Normal file
17
gitnexus/test/fixtures/lang-resolution/csharp-hello/Hello.cs
vendored
Normal 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();
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
93
gitnexus/test/unit/group-service-not-found.test.ts
Normal file
93
gitnexus/test/unit/group-service-not-found.test.ts
Normal 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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
308
gitnexus/test/unit/sibling-clone-drift.test.ts
Normal file
308
gitnexus/test/unit/sibling-clone-drift.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue