fix(storage): keep subdirectories of a clone out of shared stores (#3374)

Trigger: `analyze --skip-git <clone>/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) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-25 08:15:27 +00:00
parent a882494e36
commit 3e9035e322
2 changed files with 38 additions and 2 deletions

View file

@ -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 <clone>/pkg` would join (#3374).
*/
export const resolveOptedInStore = async (
repoPath: string,
shareWith: string | undefined,
): Promise<SharedStoreLayout | undefined> => {
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;

View file

@ -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 <clone>/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/,
);
});
});