From 6aaeee994f32ae7cc60ebcc017fe9a962dcacae7 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 24 Sep 2026 08:02:26 +0000 Subject: [PATCH] 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 , 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) --- gitnexus/src/cli/analyze-options.ts | 4 + gitnexus/src/cli/analyze.ts | 2 + gitnexus/src/cli/index.ts | 6 + gitnexus/src/core/run-analyze.ts | 24 ++- gitnexus/src/core/shared-store-analyze.ts | 79 +++++++++- .../shared-store-clone-optin.test.ts | 141 ++++++++++++++++++ 6 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 gitnexus/test/integration/shared-store-clone-optin.test.ts diff --git a/gitnexus/src/cli/analyze-options.ts b/gitnexus/src/cli/analyze-options.ts index 97e969a25..d778ed1bb 100644 --- a/gitnexus/src/cli/analyze-options.ts +++ b/gitnexus/src/cli/analyze-options.ts @@ -103,6 +103,10 @@ export interface AnalyzeOptions { * `allowDuplicateName` option end-to-end. */ allowDuplicateName?: boolean; + /** `--share-with `: 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 diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index dcaf55934..cb5d58ca6 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -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. diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 897c03764..428fba64c 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -145,6 +145,12 @@ program 'Register this repo even if another path already uses the same --name alias. ' + 'Leaves `-r ` ambiguous for the two paths; use -r to disambiguate.', ) + .option( + '--share-with ', + '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 /.gitnexus again') .option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)') .option( '--max-file-size ', diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 3e4a88d83..82386be31 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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 `/.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 { diff --git a/gitnexus/src/core/shared-store-analyze.ts b/gitnexus/src/core/shared-store-analyze.ts index c969323ad..1ba8ea52b 100644 --- a/gitnexus/src/core/shared-store-analyze.ts +++ b/gitnexus/src/core/shared-store-analyze.ts @@ -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 => { + 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 => { + 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 + * `/.gitnexus`: delete its old store slot and reclaim what only that + * slot referenced. + */ +export const leaveSharedStore = async (previousSlot: string, log: Log): Promise => { + await fs.rm(previousSlot, { recursive: true, force: true }); + await reclaimAfterSlotRemoval(previousSlot); + log(`Shared store: left ${previousSlot}.`); +}; diff --git a/gitnexus/test/integration/shared-store-clone-optin.test.ts b/gitnexus/test/integration/shared-store-clone-optin.test.ts new file mode 100644 index 000000000..ce1d40d76 --- /dev/null +++ b/gitnexus/test/integration/shared-store-clone-optin.test.ts @@ -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>; + let tmpRepo: Awaited>; + let savedHome: string | undefined; + let root: string; + let main: string; + let wt: string; + let storeLayout: SharedStoreLayout; + + const analyze = async (checkout: string, options: Record = {}) => { + 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 => + (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); +});