From 95a38c7e2d1bccafbcb08c2043496342ec406fa5 Mon Sep 17 00:00:00 2001 From: Sam Fakhreddine Date: Tue, 21 Apr 2026 08:52:36 -0600 Subject: [PATCH 1/6] fix(group): surface friendly error when group name not found (#903 regression test) (#989) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(group): surface friendly error when group name not found Squashed commits: - test(csharp): add #903 regression — parse completeness for single-file C# repo - fix(group): add GroupNotFoundError guard to groupList + re-throw tests for groupQuery/groupStatus - fix(test): restore section comments in csharp.test.ts stripped during rebase * fix(group): catch GroupNotFoundError explicitly in groupContext and groupImpact --- gitnexus/src/core/group/config-parser.ts | 17 +++- gitnexus/src/core/group/cross-impact.ts | 10 +- gitnexus/src/core/group/service.ts | 51 ++++++++-- .../lang-resolution/csharp-hello/Hello.cs | 17 ++++ .../test/integration/resolvers/csharp.test.ts | 78 +++++++++++++++- .../test/unit/group-service-not-found.test.ts | 93 +++++++++++++++++++ 6 files changed, 247 insertions(+), 19 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-hello/Hello.cs create mode 100644 gitnexus/test/unit/group-service-not-found.test.ts 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/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/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/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.', + }); + }); +}); From 6ad53ff5c772355b343465f650b271c495d9b95a Mon Sep 17 00:00:00 2001 From: evolution Date: Wed, 22 Apr 2026 04:31:58 +0800 Subject: [PATCH 2/6] fix(ci): Change docker base image from alpine to debian (#1014) * fix(docker): switch Dockerfile.cli from Alpine to Debian slim Alpine uses musl libc which is incompatible with @ladybugdb/core's glibc-compiled native binary, causing ERR_DLOPEN_FAILED on startup. Closes #1008 * fix(docker): resolve build and runtime failures in Dockerfile.cli - Add **/*.tsbuildinfo to .dockerignore and rm -f tsbuildinfo in builder to prevent stale incremental cache from skipping gitnexus-shared compilation - Install libstdc++6 from Debian Trixie for @ladybugdb/core native module compatibility (requires GLIBCXX_3.4.31) * fix(docker): use node:22-trixie-slim for GLIBCXX_3.4.31 support Replaces the manual Trixie libstdc++6 backport with the official node:22-trixie-slim base image, which ships GCC 14 runtime natively. --- .dockerignore | 2 ++ Dockerfile.cli | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) 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 From 5a109e32b649ec76f77d25c8b7833c5c24e64b76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:32:48 +0100 Subject: [PATCH 3/6] chore(deps)(deps-dev): bump @types/uuid in /gitnexus (#1016) Bumps [@types/uuid](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/uuid) from 10.0.0 to 11.0.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/uuid) --- updated-dependencies: - dependency-name: "@types/uuid" dependency-version: 11.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 14 +++++++++----- gitnexus/package.json | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 14d9cb2c8..79f6d0b59 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", @@ -2097,11 +2097,15 @@ } }, "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", 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", From d4fbf108a1b6cf605fd29b26146bfce228a2656d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:33:03 +0100 Subject: [PATCH 4/6] chore(deps)(deps-dev): bump vitest from 4.1.4 to 4.1.5 in /gitnexus (#1017) Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.4 to 4.1.5. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 258 ++++++++++++++++++------------------- 1 file changed, 129 insertions(+), 129 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 79f6d0b59..5166377ca 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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" }, @@ -2108,14 +2108,14 @@ } }, "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", @@ -2129,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": { @@ -2139,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" }, @@ -2157,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" }, @@ -2184,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": { @@ -2197,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": { @@ -2211,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" }, @@ -2227,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": { @@ -2237,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" }, @@ -4531,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" @@ -4547,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": { @@ -5476,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" @@ -5554,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", @@ -5594,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" From 064f0f5f552b06ab58874f92c602774c64fdce74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:33:16 +0100 Subject: [PATCH 5/6] chore(deps)(deps-dev): bump @vitest/coverage-v8 in /gitnexus (#1018) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.4 to 4.1.5. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 962f22482b3f85e2dc9712ff78e7ca92d2e23000 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:58:54 +0100 Subject: [PATCH 6/6] feat(cli): Fingerprint indexed repos by remote URL to detect sibling-clone graph drift (#982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat: detect sibling-clone graph drift via remote URL fingerprint Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e5decb67-7fec-40e7-b2a1-b5e94a0d393f Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: address review feedback — fake commit, same-commit case, regex docs Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e5decb67-7fec-40e7-b2a1-b5e94a0d393f Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(mcp): address review feedback — CI green, perf, dead branch, one-shot test Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc2259f7-94e4-4243-aaa9-e03b7c632d32 * Merge branch 'main' into copilot/fix-single-path-indexing-issue Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5840b3dd-e879-4854-a067-d1622bec2634 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * Merge branch 'main' into copilot/fix-single-path-indexing-issue Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9025262f-4dd4-4774-8f32-e14434100004 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * style: prettier format run-analyze.ts after merge with main Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a7be18dd-102f-4a7b-ac56-53fbd414fe3b Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: realpath both sides of cwdGitRoot assertion for Windows 8.3 short-name compat Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b2a1c6a3-e454-4b87-b0e4-69d7c0d9a51b Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(test): use path-agnostic assertion for cwdGitRoot on Windows (#1015) git rev-parse --show-toplevel returns long path names on Windows while os.tmpdir() returns 8.3 short names. fs.realpathSync does not expand short names, so exact path comparison always fails on Windows CI runners. Replace with behavioral assertions instead. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] Co-authored-by: Gergő Magyar Co-authored-by: evolution --- gitnexus/src/cli/index-repo.ts | 9 +- gitnexus/src/core/git-staleness.ts | 111 +++++++ gitnexus/src/core/run-analyze.ts | 9 +- gitnexus/src/mcp/local/local-backend.ts | 154 ++++++++- gitnexus/src/storage/git.ts | 60 ++++ gitnexus/src/storage/repo-manager.ts | 77 +++++ .../test/integration/api-impact-e2e.test.ts | 1 + .../class-impact-all-languages.test.ts | 1 + .../integration/java-class-impact.test.ts | 1 + .../local-backend-calltool.test.ts | 1 + .../shape-check-regression.test.ts | 1 + .../staleness-and-stability.test.ts | 1 + gitnexus/test/unit/calltool-dispatch.test.ts | 48 +++ gitnexus/test/unit/git-utils.test.ts | 69 ++++ gitnexus/test/unit/index-repo-command.test.ts | 5 + .../test/unit/sibling-clone-drift.test.ts | 308 ++++++++++++++++++ 16 files changed, 844 insertions(+), 12 deletions(-) create mode 100644 gitnexus/test/unit/sibling-clone-drift.test.ts 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/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/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/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/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(); + } + }); +});