From 3e9035e322d97295be837593a4f69aca9b97b563 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Fri, 25 Sep 2026 08:15:27 +0000 Subject: [PATCH] fix(storage): keep subdirectories of a clone out of shared stores (#3374) Trigger: `analyze --skip-git /pkg` inside a clone with a registered sibling of the same origin joined, or founded, a clone store. getRemoteUrl answers from any subdirectory, so siblingCloneStore saw the enclosing clone's remote. That broke the shared-store invariant that only tree roots participate. Fix: resolveOptedInStore now returns undefined for a path with no `.git` entry. It uses hasGitDir, which accepts a directory or a linked-worktree file, the same as resolveSharedStore's readCommonDir gate and run-analyze's repoHasGit. `--share-with` from such a path throws a clear error. Co-Authored-By: Claude Opus 5.5 (1M context) --- gitnexus/src/core/shared-store-analyze.ts | 18 +++++++++++++-- .../shared-store-clone-optin.test.ts | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/gitnexus/src/core/shared-store-analyze.ts b/gitnexus/src/core/shared-store-analyze.ts index ed33888ac..a831ccab0 100644 --- a/gitnexus/src/core/shared-store-analyze.ts +++ b/gitnexus/src/core/shared-store-analyze.ts @@ -20,7 +20,12 @@ import { existsSync, constants as fsConstants } from 'fs'; import fs from 'fs/promises'; import path from 'path'; import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js'; -import { commitDistanceToHead, getRemoteUrl, isWorkingTreePristine } from '../storage/git.js'; +import { + commitDistanceToHead, + getRemoteUrl, + hasGitDir, + isWorkingTreePristine, +} from '../storage/git.js'; import { canonicalizePath, findRegistryEntryByRepoPath, @@ -534,12 +539,21 @@ const siblingCloneStore = ( * `--share-with`, the one its registry entry already points into, or a * sibling clone's store (#3352). `--share-with` requires the normalized remote * URL to match the member it names (R3); `analyze --no-share` records an - * opt-out that stops automatic joining. + * opt-out that stops automatic joining. Only tree roots participate, as in + * `resolveSharedStore`: `getRemoteUrl` answers from any subdirectory, so + * without this gate `analyze --skip-git /pkg` would join (#3374). */ export const resolveOptedInStore = async ( repoPath: string, shareWith: string | undefined, ): Promise => { + if (!hasGitDir(repoPath)) { + if (!shareWith) return undefined; + throw new Error( + `--share-with: "${repoPath}" is not the root of a git checkout. ` + + 'Only a clone root can share an index store.', + ); + } const entries = await readRegistry(); if (shareWith) { let target; diff --git a/gitnexus/test/integration/shared-store-clone-optin.test.ts b/gitnexus/test/integration/shared-store-clone-optin.test.ts index 150510b61..97b045ce8 100644 --- a/gitnexus/test/integration/shared-store-clone-optin.test.ts +++ b/gitnexus/test/integration/shared-store-clone-optin.test.ts @@ -309,4 +309,26 @@ describe('shared store founder key for concurrent sibling clones (#3374)', () => const joiner = await cloneAndRegister('aaa-joiner'); expect((await resolveOptedInStore(joiner, undefined))?.key).toBe(existing); }); + + // #3374 S8 — `getRemoteUrl` answers from any subdirectory, so only the + // tree-root gate keeps `analyze --skip-git /pkg` out of the store. + it('a subdirectory of a clone with a registered sibling neither joins nor founds a store', async () => { + await cloneAndRegister('member', cloneStoreKey(path.join(root, 'zz-founder'))); + const clone = await cloneAndRegister('other'); + const subdir = path.join(clone, 'pkg'); + await fs.mkdir(subdir); + + expect(await resolveOptedInStore(subdir, undefined)).toBeUndefined(); + expect(await resolveOptedInStore(clone, undefined)).toBeDefined(); + }); + + it('--share-with refuses a subdirectory of a clone', async () => { + const member = await cloneAndRegister('member', cloneStoreKey(path.join(root, 'zz-founder'))); + const subdir = path.join(await cloneAndRegister('other'), 'pkg'); + await fs.mkdir(subdir); + + await expect(resolveOptedInStore(subdir, member)).rejects.toThrow( + /--share-with: .* is not the root of a git checkout/, + ); + }); });