feat(analyze): let a clone opt in to a shared store with --share-with (#3352)

An independent clone joins a linked worktree's shared store only with
analyze --share-with <repo>, and only when its normalized origin URL
matches that member's (credentials stripped, as #2054 compares). The
registry remembers the choice. --no-share moves an opted-in clone back
to its own .gitnexus and reclaims its old slot; linked worktrees always
share and are pointed at GITNEXUS_SHARED_STORE=off instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-24 08:02:26 +00:00
parent 8c74a86fc3
commit 6aaeee994f
6 changed files with 251 additions and 5 deletions

View file

@ -103,6 +103,10 @@ export interface AnalyzeOptions {
* `allowDuplicateName` option end-to-end.
*/
allowDuplicateName?: boolean;
/** `--share-with <repo>`: join that checkout's shared store (#3352). */
shareWith?: string;
/** `--no-share` sets this to false: leave the shared store (#3352). */
share?: boolean;
/**
* Override the walker's large-file skip threshold (#991). Value in KB;
* clamped downstream to the tree-sitter 32 MB ceiling. Sets

View file

@ -1396,6 +1396,8 @@ const analyzeCommandImpl = async (
// be able to accept the duplicate name without also paying the
// cost of a full pipeline re-index. See #829 review round 2.
allowDuplicateName: options.allowDuplicateName,
shareWith: options.shareWith,
noShare: options.share === false,
// Worker pool size threaded from --workers, replacing the previous
// GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
// env / auto-formula fallback inside the pipeline.

View file

@ -145,6 +145,12 @@ program
'Register this repo even if another path already uses the same --name alias. ' +
'Leaves `-r <name>` ambiguous for the two paths; use -r <path> to disambiguate.',
)
.option(
'--share-with <repo>',
'Join the shared index store of a registered worktree of the same repository ' +
'(name or path); the remote URL must match. Remembered for later runs.',
)
.option('--no-share', 'Leave the shared index store and index into <repo>/.gitnexus again')
.option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
.option(
'--max-file-size <kb>',

View file

@ -162,6 +162,7 @@ import {
import {
ANALYZE_FORCE_STORAGE_REQUIREMENTS,
ANALYZE_STORAGE_REQUIREMENTS,
defaultStoragePath,
requireRegisteredStoragePath,
requireStoragePath,
} from '../storage/storage-resolver.js';
@ -170,7 +171,10 @@ import { LBUG_DIRECTORY } from '../storage/storage-constants.js';
import {
ensurePrivateSharedGraph,
listStoreMetaRoots,
leaveSharedStore,
optedInSlotToLeave,
publishSharedGraph,
resolveOptedInStore,
seedSharedSlot,
withStoreLock,
} from './shared-store-analyze.js';
@ -538,6 +542,13 @@ export interface AnalyzeOptions {
* of a pipeline re-index.
*/
allowDuplicateName?: boolean;
/**
* Join the shared store of this registered worktree (name or path), after
* checking the remote URL matches (#3352). Persisted through the registry.
*/
shareWith?: string;
/** Leave the shared store and index into `<repo>/.gitnexus` (#3352). */
noShare?: boolean;
/**
* Worker pool size override, threaded from the CLI `--workers` flag.
* Forwarded to `PipelineOptions.workerPoolSize` so the parse phase
@ -1119,10 +1130,14 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
const storageRequirements = options.force
? ANALYZE_FORCE_STORAGE_REQUIREMENTS
: ANALYZE_STORAGE_REQUIREMENTS;
const sharedStore = resolveSharedStore(repoPath) ?? undefined;
const storagePath = sharedStore
const sharedStore = options.noShare
? undefined
: (resolveSharedStore(repoPath) ?? (await resolveOptedInStore(repoPath, options.shareWith)));
const explicitStorage =
sharedStore?.checkoutSlot ?? (options.noShare ? defaultStoragePath(repoPath) : undefined);
const storagePath = explicitStorage
? await requireRegisteredStoragePath(
{ path: repoPath, storagePath: sharedStore.checkoutSlot },
{ path: repoPath, storagePath: explicitStorage },
storageRequirements,
)
: await requireStoragePath(repoPath, storageRequirements);
@ -1281,6 +1296,7 @@ export async function runFullAnalysis(
}
const flatShared = writeTarget.placement.branch ? undefined : writeTarget.sharedStore;
if (flatShared) await seedSharedSlot(flatShared, repoPath, log);
const slotToLeave = options.noShare ? await optedInSlotToLeave(repoPath) : undefined;
const result = await runFullAnalysisInner(
repoPath,
options,
@ -1291,6 +1307,8 @@ export async function runFullAnalysis(
);
if (flatShared) {
await publishSharedGraph(flatShared, repoPath, writeTarget.currentCommit, log);
} else if (slotToLeave) {
await leaveSharedStore(slotToLeave, log);
}
return result;
} finally {

View file

@ -21,14 +21,23 @@ import fs from 'fs/promises';
import path from 'path';
import { acquireIndexLock } from '../storage/index-lock.js';
import { withStoreLock } from '../storage/shared-store-lifecycle.js';
import { commitDistanceToHead, isWorkingTreeDirty } from '../storage/git.js';
import { registerRepo, saveMeta } from '../storage/repo-manager.js';
import { commitDistanceToHead, getRemoteUrl, isWorkingTreeDirty } from '../storage/git.js';
import {
readRegistry,
registerRepo,
resolveRegistryEntry,
saveMeta,
} from '../storage/repo-manager.js';
import { loadMeta, type RepoMeta } from '../storage/repo-meta.js';
import {
commitGraphDir,
resolveGraphPath,
resolveSharedStore,
sharedStoreLayout,
storeRootOfCheckoutSlot,
type SharedStoreLayout,
} from '../storage/shared-store.js';
import { reclaimAfterSlotRemoval } from '../storage/shared-store-lifecycle.js';
import { GITNEXUS_DIR, INDEX_METADATA_FILE, LBUG_DIRECTORY } from '../storage/storage-constants.js';
import { wipeLbugDbFiles } from './lbug/lbug-adapter.js';
import { inspectLbugSidecars } from './lbug/sidecar-recovery.js';
@ -333,3 +342,69 @@ export const publishSharedGraph = async (
};
export { withStoreLock };
/**
* Store for a checkout that is not a linked worktree: the store named by
* `--share-with`, or the one its registry entry already points into. Clones
* only join by explicit opt-in (#3352 R2), and only when their normalized
* remote URL matches the member they name (R3).
*/
export const resolveOptedInStore = async (
repoPath: string,
shareWith: string | undefined,
): Promise<SharedStoreLayout | undefined> => {
const entries = await readRegistry();
if (shareWith) {
let target;
try {
target = resolveRegistryEntry(entries, shareWith);
} catch {
throw new Error(`--share-with: "${shareWith}" is not a registered repository.`);
}
const root = storeRootOfCheckoutSlot(target.storagePath);
if (!root) {
throw new Error(
`--share-with: "${shareWith}" does not use a shared index store. ` +
'Name a linked worktree of the repository (analyze it first).',
);
}
const remote = getRemoteUrl(repoPath);
if (!remote || remote !== target.remoteUrl) {
throw new Error(
`--share-with: remote URL mismatch — this checkout is "${remote ?? '(no origin remote)'}", ` +
`"${target.name}" is "${target.remoteUrl ?? '(no origin remote)'}". ` +
'Only clones of the same repository can share an index store.',
);
}
return sharedStoreLayout(path.basename(root), repoPath);
}
const own = entries.find((e) => path.resolve(e.path) === path.resolve(repoPath));
const root = own ? storeRootOfCheckoutSlot(own.storagePath) : null;
return root ? sharedStoreLayout(path.basename(root), repoPath) : undefined;
};
/**
* The store slot a checkout is registered at, for `--no-share`. Linked
* worktrees always share (turn sharing off with GITNEXUS_SHARED_STORE=off),
* so only an opted-in clone can leave.
*/
export const optedInSlotToLeave = async (repoPath: string): Promise<string | undefined> => {
if (resolveSharedStore(repoPath)) {
throw new Error(
'--no-share: linked worktrees always use the shared index store. ' +
'Set GITNEXUS_SHARED_STORE=off to index every checkout into its own .gitnexus.',
);
}
return (await resolveOptedInStore(repoPath, undefined))?.checkoutSlot;
};
/**
* After a successful `--no-share` run re-registered the checkout at
* `<repo>/.gitnexus`: delete its old store slot and reclaim what only that
* slot referenced.
*/
export const leaveSharedStore = async (previousSlot: string, log: Log): Promise<void> => {
await fs.rm(previousSlot, { recursive: true, force: true });
await reclaimAfterSlotRemoval(previousSlot);
log(`Shared store: left ${previousSlot}.`);
};

View file

@ -0,0 +1,141 @@
import { execFileSync } from 'child_process';
import { existsSync } from 'fs';
import fs from 'fs/promises';
import path from 'path';
import { afterEach, 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 { createTempDir } from '../helpers/test-db.js';
/**
* #3352 U7 — an independent clone joins a shared store only by explicit
* opt-in, and only when its remote matches the member it names.
*/
const git = (cwd: string, ...args: string[]): string =>
execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
const REMOTE = 'https://example.com/acme/widgets';
describe('shared store clone opt-in (#3352)', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
let savedHome: string | undefined;
let root: string;
let main: string;
let wt: string;
let storeLayout: SharedStoreLayout;
const analyze = async (checkout: string, options: Record<string, unknown> = {}) => {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
return runFullAnalysis(checkout, options, { onProgress: () => {} });
};
const cloneWithRemote = (name: string, remote: string): string => {
const clone = path.join(root, name);
git(root, 'clone', '-q', main, clone);
git(clone, 'remote', 'set-url', 'origin', remote);
return clone;
};
const registeredStorage = async (checkout: string): Promise<string | undefined> =>
(await listRegisteredRepos()).find((e) => e.path === checkout)?.storagePath;
beforeEach(async () => {
tmpHome = await createTempDir('gitnexus-optin-home-');
tmpRepo = await createTempDir('gitnexus-optin-repo-');
savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
root = await fs.realpath(tmpRepo.dbPath);
main = path.join(root, 'main');
await fs.mkdir(main);
git(main, 'init', '-q', '-b', 'main');
git(main, 'remote', 'add', 'origin', `${REMOTE}.git`);
await fs.writeFile(path.join(main, 'a.ts'), 'export function alpha() { return 1; }\n');
git(main, 'add', '-A');
git(main, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init');
wt = path.join(root, 'wt');
git(main, 'worktree', 'add', '-q', '-b', 'wt', wt);
await analyze(wt);
storeLayout = resolveSharedStore(wt) as SharedStoreLayout;
expect(storeLayout).not.toBeNull();
}, 240_000);
afterEach(async () => {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpRepo.cleanup();
await tmpHome.cleanup();
});
it('Covers AE7: a clone that has not opted in keeps its own .gitnexus', async () => {
const clone = cloneWithRemote('clone', REMOTE);
await analyze(clone);
expect(await registeredStorage(clone)).toBe(path.join(clone, '.gitnexus'));
expect(existsSync(path.join(clone, '.gitnexus', 'lbug'))).toBe(true);
}, 240_000);
it('joins the store with --share-with and reuses the commit graph at its HEAD', async () => {
const clone = cloneWithRemote('clone', REMOTE);
const result = await analyze(clone, { shareWith: wt });
const slot = await registeredStorage(clone);
expect(path.dirname(slot as string)).toBe(storeLayout.checkoutsDir);
expect(result.alreadyUpToDate).toBe(true);
expect(getStoragePaths(clone, undefined, slot).lbugPath).toBe(
getStoragePaths(wt, undefined, storeLayout.checkoutSlot).lbugPath,
);
expect(existsSync(path.join(clone, '.gitnexus', 'lbug'))).toBe(false);
// Remembered: a later plain analyze stays in the store.
await analyze(clone);
expect(await registeredStorage(clone)).toBe(slot);
}, 240_000);
it('admits a clone whose remote differs only by embedded credentials', async () => {
const clone = cloneWithRemote('clone', 'https://user:secret@example.com/acme/widgets.git');
await analyze(clone, { shareWith: wt });
expect(path.dirname((await registeredStorage(clone)) as string)).toBe(storeLayout.checkoutsDir);
}, 240_000);
it('Covers AE4: refuses a clone of a different repository and changes nothing', async () => {
const clone = cloneWithRemote('other', 'https://example.com/acme/gadgets');
await expect(analyze(clone, { shareWith: wt })).rejects.toThrow(
/remote URL mismatch — this checkout is "https:\/\/example\.com\/acme\/gadgets"/,
);
expect(await registeredStorage(clone)).toBeUndefined();
expect(existsSync(path.join(clone, '.gitnexus', 'lbug'))).toBe(false);
}, 240_000);
it('refuses a clone with no origin remote', async () => {
const clone = cloneWithRemote('noremote', REMOTE);
git(clone, 'remote', 'remove', 'origin');
await expect(analyze(clone, { shareWith: wt })).rejects.toThrow(/\(no origin remote\)/);
}, 240_000);
it('refuses a --share-with target that is not in a shared store', async () => {
const plain = cloneWithRemote('plain', REMOTE);
await analyze(plain);
const clone = cloneWithRemote('clone', REMOTE);
await expect(analyze(clone, { shareWith: plain })).rejects.toThrow(
/does not use a shared index store/,
);
}, 240_000);
it('--no-share moves a clone back to its own .gitnexus and keeps shared graphs', async () => {
const clone = cloneWithRemote('clone', REMOTE);
await analyze(clone, { shareWith: wt });
const slot = (await registeredStorage(clone)) as string;
await analyze(clone, { noShare: true });
expect(await registeredStorage(clone)).toBe(path.join(clone, '.gitnexus'));
expect(existsSync(slot)).toBe(false);
expect(existsSync(getStoragePaths(wt, undefined, storeLayout.checkoutSlot).lbugPath)).toBe(
true,
);
}, 240_000);
it('rejects --no-share in a linked worktree', async () => {
await expect(analyze(wt, { noShare: true })).rejects.toThrow(/GITNEXUS_SHARED_STORE=off/);
}, 240_000);
});