mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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:
parent
8c74a86fc3
commit
6aaeee994f
6 changed files with 251 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}.`);
|
||||
};
|
||||
|
|
|
|||
141
gitnexus/test/integration/shared-store-clone-optin.test.ts
Normal file
141
gitnexus/test/integration/shared-store-clone-optin.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue