diff --git a/gitnexus/src/core/shared-store-analyze.ts b/gitnexus/src/core/shared-store-analyze.ts index 0cffb4dfc..ed33888ac 100644 --- a/gitnexus/src/core/shared-store-analyze.ts +++ b/gitnexus/src/core/shared-store-analyze.ts @@ -491,10 +491,14 @@ const registeredStore = ( /** * Store for a clone with no store of its own: the store a registered sibling * clone (same normalized `origin` URL, checkout still present) already uses, - * or, when siblings exist but none shares yet, a new store keyed on this - * clone. Graphs are keyed by commit and feature key, so clones only ever share - * a graph built from the same commit with the same settings. A lone clone - * keeps its repository-local index. + * or, when siblings exist but none shares yet, a new store keyed on the + * canonical path that sorts first among this clone and its siblings. Every + * sibling computes that same founder key, so clones founding the store + * concurrently still land in one store (#3374); the key is only a name, so the + * store keeps working if the founder's checkout is later deleted. Graphs are + * keyed by commit and feature key, so clones only ever share a graph built + * from the same commit with the same settings. A lone clone keeps its + * repository-local index. */ const siblingCloneStore = ( entries: readonly RegistryEntry[], @@ -515,7 +519,14 @@ const siblingCloneStore = ( .filter((root): root is string => root !== null) .map((root) => path.basename(root)) .sort(); - return sharedStoreLayout(keys[0] ?? cloneStoreKey(repoPath), repoPath); + if (keys[0]) return sharedStoreLayout(keys[0], repoPath); + // Order the way the registry compares paths: case-folded on Windows, whose + // slot names hash the folded form too. + const fold = (p: string): string => (registryPathEquals('A', 'a') ? p.toLowerCase() : p); + const founder = [self, ...siblings.map((e) => canonicalizePath(e.path))].reduce((a, b) => + fold(b) < fold(a) ? b : a, + ); + return sharedStoreLayout(cloneStoreKey(founder), repoPath); }; /** diff --git a/gitnexus/test/integration/shared-store-clone-optin.test.ts b/gitnexus/test/integration/shared-store-clone-optin.test.ts index e175d418b..150510b61 100644 --- a/gitnexus/test/integration/shared-store-clone-optin.test.ts +++ b/gitnexus/test/integration/shared-store-clone-optin.test.ts @@ -3,8 +3,19 @@ import { existsSync } from 'fs'; import fs from 'fs/promises'; import path from 'path'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { getStoragePaths, listRegisteredRepos } from '../../src/storage/repo-manager.js'; -import { resolveSharedStore, type SharedStoreLayout } from '../../src/storage/shared-store.js'; +import { resolveOptedInStore } from '../../src/core/shared-store-analyze.js'; +import { getRemoteUrl } from '../../src/storage/git.js'; +import { + getStoragePaths, + listRegisteredRepos, + registerRepo, +} from '../../src/storage/repo-manager.js'; +import { + cloneStoreKey, + resolveSharedStore, + sharedStoreLayout, + type SharedStoreLayout, +} from '../../src/storage/shared-store.js'; import { createTempDir } from '../helpers/test-db.js'; // These suites exercise sharing; an inherited opt-out would silently disable it. @@ -225,3 +236,77 @@ describe('shared store clone sharing (#3352)', () => { ); }, 240_000); }); + +/** + * #3374 S7 — registered sibling clones that found a store at the same time + * must pick the same key, or each keeps its own store forever. Resolves the + * store directly (no analyze): only the registry and the clones' remotes matter. + */ +describe('shared store founder key for concurrent sibling clones (#3374)', () => { + let tmpHome: Awaited>; + let tmpRepo: Awaited>; + let savedHome: string | undefined; + let root: string; + + /** Clone `source` as `name` and register it at local storage, or at `storeKey`'s slot. */ + const cloneAndRegister = async (name: string, storeKey?: string): Promise => { + const clone = path.join(root, name); + git(root, 'clone', '-q', path.join(root, 'source'), clone); + git(clone, 'remote', 'set-url', 'origin', REMOTE); + const storagePath = storeKey ? sharedStoreLayout(storeKey, clone).checkoutSlot : undefined; + await registerRepo( + clone, + { + repoPath: clone, + storagePath, + lastCommit: git(clone, 'rev-parse', 'HEAD'), + indexedAt: new Date(0).toISOString(), + remoteUrl: getRemoteUrl(clone), + }, + storagePath ? { storagePath } : undefined, + ); + return clone; + }; + + beforeEach(async () => { + tmpHome = await createTempDir('gitnexus-test-founder-home-'); + tmpRepo = await createTempDir('gitnexus-test-founder-repo-'); + savedHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = tmpHome.dbPath; + root = await fs.realpath(tmpRepo.dbPath); + const source = path.join(root, 'source'); + await fs.mkdir(source); + git(source, 'init', '-q', '-b', 'main'); + await fs.writeFile(path.join(source, 'a.ts'), 'export const a = 1;\n'); + git(source, 'add', '-A'); + git(source, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init'); + }); + + afterEach(async () => { + if (savedHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedHome; + await tmpRepo.cleanup(); + await tmpHome.cleanup(); + }); + + it('two registered local clones resolve the same new store, keyed on the first path', async () => { + // Registered in reverse path order so registry order cannot pick the key. + const second = await cloneAndRegister('zeta'); + const first = await cloneAndRegister('alpha'); + + const fromFirst = await resolveOptedInStore(first, undefined); + const fromSecond = await resolveOptedInStore(second, undefined); + + expect(fromFirst?.key).toBe(cloneStoreKey(first)); + expect(fromSecond?.key).toBe(cloneStoreKey(first)); + }); + + it('a later clone joins the existing store rather than founding one', async () => { + const existing = cloneStoreKey(path.join(root, 'zz-founder')); + await cloneAndRegister('member', existing); + await cloneAndRegister('other'); + + const joiner = await cloneAndRegister('aaa-joiner'); + expect((await resolveOptedInStore(joiner, undefined))?.key).toBe(existing); + }); +});