mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
feat(analyze): share one immutable commit graph across clean worktrees (#3352)
A linked worktree writes its own slot in the shared store. A new slot is seeded with a pointer to the nearest commit graph, so a clean checkout at an indexed commit takes the up-to-date path and writes no graph. A checkout with local changes gets a copy-on-write private graph before its first write. After a successful run, a clean checkout at HEAD publishes its graph into commits/ under a store lock, or drops it when that commit graph already exists. Commit graphs are never written after publish. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
91c423d107
commit
3ca01b4f5a
4 changed files with 596 additions and 6 deletions
|
|
@ -162,8 +162,16 @@ import {
|
|||
import {
|
||||
ANALYZE_FORCE_STORAGE_REQUIREMENTS,
|
||||
ANALYZE_STORAGE_REQUIREMENTS,
|
||||
requireRegisteredStoragePath,
|
||||
requireStoragePath,
|
||||
} from '../storage/storage-resolver.js';
|
||||
import { resolveSharedStore, type SharedStoreLayout } from '../storage/shared-store.js';
|
||||
import { LBUG_DIRECTORY } from '../storage/storage-constants.js';
|
||||
import {
|
||||
ensurePrivateSharedGraph,
|
||||
publishSharedGraph,
|
||||
seedSharedSlot,
|
||||
} from './shared-store-analyze.js';
|
||||
import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js';
|
||||
import {
|
||||
DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
|
||||
|
|
@ -1085,6 +1093,8 @@ interface WriteTarget {
|
|||
lbugPath: string;
|
||||
metaPath: string;
|
||||
metaDir: string;
|
||||
/** Set when this checkout writes into a shared sibling store (#3352). */
|
||||
sharedStore?: SharedStoreLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1101,10 +1111,19 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
|
|||
// a cached path string must not skip ownership (STORAGE_PATH can move to a
|
||||
// foreign slot while the lock is waited out). `--force` may adopt a
|
||||
// repository-local foreign slot; the non-force set stays ANALYZE_STORAGE.
|
||||
const storagePath = await requireStoragePath(
|
||||
repoPath,
|
||||
options.force ? ANALYZE_FORCE_STORAGE_REQUIREMENTS : ANALYZE_STORAGE_REQUIREMENTS,
|
||||
);
|
||||
// A linked-worktree checkout writes its own slot in the shared store
|
||||
// (#3352); that slot replaces any repository-local `.gitnexus`, which is left
|
||||
// untouched.
|
||||
const storageRequirements = options.force
|
||||
? ANALYZE_FORCE_STORAGE_REQUIREMENTS
|
||||
: ANALYZE_STORAGE_REQUIREMENTS;
|
||||
const sharedStore = resolveSharedStore(repoPath) ?? undefined;
|
||||
const storagePath = sharedStore
|
||||
? await requireRegisteredStoragePath(
|
||||
{ path: repoPath, storagePath: sharedStore.checkoutSlot },
|
||||
storageRequirements,
|
||||
)
|
||||
: await requireStoragePath(repoPath, storageRequirements);
|
||||
const repoHasGit = hasGitDir(repoPath);
|
||||
const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
|
||||
// Normalize the auto-detected branch the same way an explicit `--branch` is
|
||||
|
|
@ -1131,7 +1150,11 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
|
|||
const placement = options.branch
|
||||
? await resolveBranchPlacement(repoPath, branchLabel, storagePath)
|
||||
: {};
|
||||
const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch, storagePath);
|
||||
const paths = getStoragePaths(repoPath, placement.branch, storagePath);
|
||||
const { metaPath } = paths;
|
||||
// Analyze always writes a shared slot's own graph; a recorded `graphPath`
|
||||
// only redirects readers.
|
||||
const lbugPath = sharedStore ? path.join(path.dirname(metaPath), LBUG_DIRECTORY) : paths.lbugPath;
|
||||
return {
|
||||
storagePath,
|
||||
repoHasGit,
|
||||
|
|
@ -1143,6 +1166,7 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
|
|||
lbugPath,
|
||||
metaPath,
|
||||
metaDir: path.dirname(metaPath),
|
||||
sharedStore,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1253,7 +1277,9 @@ export async function runFullAnalysis(
|
|||
`Warning: checkout "${formatRejectedBranchForLog(writeTarget.rejectedDetectedBranch)}" is not a usable index label; continuing.`,
|
||||
);
|
||||
}
|
||||
return await runFullAnalysisInner(
|
||||
const flatShared = writeTarget.placement.branch ? undefined : writeTarget.sharedStore;
|
||||
if (flatShared) await seedSharedSlot(flatShared, repoPath, log);
|
||||
const result = await runFullAnalysisInner(
|
||||
repoPath,
|
||||
options,
|
||||
callbacks,
|
||||
|
|
@ -1261,6 +1287,10 @@ export async function runFullAnalysis(
|
|||
contentRetention,
|
||||
runnerIdentityAtBootstrap,
|
||||
);
|
||||
if (flatShared) {
|
||||
await publishSharedGraph(flatShared, repoPath, writeTarget.currentCommit, log);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
discardScopedEmbeddingSpills();
|
||||
lock.release();
|
||||
|
|
@ -1326,7 +1356,16 @@ async function runFullAnalysisInner(
|
|||
log(`Metadata reconciliation failed (non-critical${code ? `, ${code}` : ''}); continuing.`);
|
||||
}
|
||||
|
||||
// Shared-store pointer slots (#3352) get a private graph just before the
|
||||
// first graph open: here for the paths that open it before the up-to-date
|
||||
// check, and below once that check falls through.
|
||||
const ensurePrivateGraph = async (): Promise<void> => {
|
||||
if (writeTarget.sharedStore && !placement.branch) {
|
||||
await ensurePrivateSharedGraph(metaDir, log);
|
||||
}
|
||||
};
|
||||
const loadedMeta = await loadMeta(metaDir);
|
||||
if (loadedMeta?.incrementalInProgress || options.repairFts) await ensurePrivateGraph();
|
||||
if (options.preserveExistingPdg && options.pdg === undefined) {
|
||||
if (loadedMeta) {
|
||||
options = { ...options, pdg: loadedMeta.pdg !== undefined };
|
||||
|
|
@ -2280,6 +2319,7 @@ async function runFullAnalysisInner(
|
|||
}
|
||||
|
||||
await ensureWritableStorage();
|
||||
await ensurePrivateGraph();
|
||||
|
||||
// ── Cache embeddings from existing index before rebuild ────────────
|
||||
// Four modes:
|
||||
|
|
|
|||
259
gitnexus/src/core/shared-store-analyze.ts
Normal file
259
gitnexus/src/core/shared-store-analyze.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* Analyze-side operations for the shared sibling store (#3352).
|
||||
*
|
||||
* A shared checkout slot is in one of two states:
|
||||
* - pointer: metadata records `graphPath` (a commit graph in the store);
|
||||
* the slot has no graph of its own.
|
||||
* - private: the slot owns `<slot>/lbug`; no `graphPath`.
|
||||
*
|
||||
* Analyze always writes the private path. `ensurePrivateSharedGraph` turns a
|
||||
* pointer into a private copy just before the first graph open/write, so a
|
||||
* clean checkout that hits the up-to-date fast path copies nothing.
|
||||
* `publishSharedGraph` runs after a successful analyze: a clean checkout at
|
||||
* HEAD moves its private graph into `commits/` (or drops it when that commit
|
||||
* graph already exists) and becomes a pointer again. Commit graphs are never
|
||||
* written after publish.
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { constants as fsConstants } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js';
|
||||
import { commitDistanceToHead, isWorkingTreeDirty } from '../storage/git.js';
|
||||
import { registerRepo, saveMeta } from '../storage/repo-manager.js';
|
||||
import { loadMeta, type RepoMeta } from '../storage/repo-meta.js';
|
||||
import {
|
||||
commitGraphDir,
|
||||
resolveGraphPath,
|
||||
type SharedStoreLayout,
|
||||
} from '../storage/shared-store.js';
|
||||
import { INDEX_METADATA_FILE, LBUG_DIRECTORY } from '../storage/storage-constants.js';
|
||||
import { wipeLbugDbFiles } from './lbug/lbug-adapter.js';
|
||||
import { inspectLbugSidecars } from './lbug/sidecar-recovery.js';
|
||||
|
||||
type Log = (msg: string) => void;
|
||||
|
||||
/**
|
||||
* Fields that differ between checkouts or runs of the same content and
|
||||
* settings. Everything else in the metadata — schema fingerprint, analysis
|
||||
* features, capabilities, retention, runner identity, PDG and process
|
||||
* settings — must match for two checkouts to share a graph. A denylist fails
|
||||
* safe: an unexpected per-run field only prevents sharing, never mixes graphs.
|
||||
*/
|
||||
const FEATURE_KEY_EXCLUDED = new Set<string>([
|
||||
'repoPath',
|
||||
'storagePath',
|
||||
'graphPath',
|
||||
'lastCommit',
|
||||
'indexedAt',
|
||||
'branch',
|
||||
'remoteUrl',
|
||||
'fileHashes',
|
||||
'cacheKeys',
|
||||
'incrementalInProgress',
|
||||
'embeddingCheckpoint',
|
||||
'stats',
|
||||
]);
|
||||
|
||||
/** Fields that describe one checkout; stripped from a published commit graph's metadata. */
|
||||
const CHECKOUT_FIELDS = ['repoPath', 'storagePath', 'graphPath', 'branch', 'incrementalInProgress'];
|
||||
|
||||
const stableStringify = (value: unknown): string => {
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
||||
if (value && typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
|
||||
/** Hash of every graph-affecting metadata field (see FEATURE_KEY_EXCLUDED). */
|
||||
export const featureKeyOf = (meta: RepoMeta): string => {
|
||||
const kept: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(meta)) {
|
||||
if (!FEATURE_KEY_EXCLUDED.has(k)) kept[k] = v;
|
||||
}
|
||||
// Embedding presence changes graph content but lives only in `stats`.
|
||||
kept.hasEmbeddings = (meta.stats?.embeddings ?? 0) > 0;
|
||||
return createHash('sha256').update(stableStringify(kept)).digest('hex').slice(0, 16);
|
||||
};
|
||||
|
||||
const exists = (p: string): Promise<boolean> =>
|
||||
fs.access(p).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
interface CommitGraph {
|
||||
dir: string;
|
||||
commit: string;
|
||||
meta: RepoMeta;
|
||||
}
|
||||
|
||||
const listCommitGraphs = async (layout: SharedStoreLayout): Promise<CommitGraph[]> => {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(layout.commitsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const graphs: CommitGraph[] = [];
|
||||
for (const name of names) {
|
||||
const match = /^([0-9a-f]{7,64})-([0-9a-f]{8,64})$/.exec(name);
|
||||
if (!match) continue;
|
||||
const dir = path.join(layout.commitsDir, name);
|
||||
const meta = await loadMeta(dir);
|
||||
if (!meta || !(await exists(path.join(dir, LBUG_DIRECTORY)))) continue;
|
||||
graphs.push({ dir, commit: match[1], meta });
|
||||
}
|
||||
return graphs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick the commit graph to seed a new checkout slot from: the one at HEAD,
|
||||
* else the ancestor with the fewest commits to HEAD. Ties go to the most
|
||||
* recently indexed graph.
|
||||
* ponytail: one `git` call pair per commit graph; fine for tens of graphs,
|
||||
* batch through `git rev-list` if stores grow to hundreds.
|
||||
*/
|
||||
const pickSeed = (repoPath: string, graphs: CommitGraph[]): CommitGraph | null => {
|
||||
let best: { graph: CommitGraph; distance: number } | null = null;
|
||||
for (const graph of graphs) {
|
||||
const distance = commitDistanceToHead(repoPath, graph.commit);
|
||||
if (distance === null) continue;
|
||||
const better =
|
||||
!best ||
|
||||
distance < best.distance ||
|
||||
(distance === best.distance && graph.meta.indexedAt > best.graph.meta.indexedAt);
|
||||
if (better) best = { graph, distance };
|
||||
}
|
||||
return best?.graph ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Give a slot with no metadata a pointer to the best commit graph, so the
|
||||
* run that follows is up to date (same commit, clean) or incremental from
|
||||
* that graph's file hashes. Caller holds the slot's index lock.
|
||||
*/
|
||||
export const seedSharedSlot = async (
|
||||
layout: SharedStoreLayout,
|
||||
repoPath: string,
|
||||
log: Log,
|
||||
): Promise<void> => {
|
||||
if (await loadMeta(layout.checkoutSlot)) return;
|
||||
const seed = pickSeed(repoPath, await listCommitGraphs(layout));
|
||||
if (!seed) return;
|
||||
await fs.mkdir(layout.checkoutSlot, { recursive: true });
|
||||
const meta: RepoMeta = {
|
||||
...seed.meta,
|
||||
repoPath,
|
||||
storagePath: layout.checkoutSlot,
|
||||
graphPath: path.join(seed.dir, LBUG_DIRECTORY),
|
||||
};
|
||||
delete meta.incrementalInProgress;
|
||||
await saveMeta(layout.checkoutSlot, meta);
|
||||
log(`Shared store: seeded from commit graph ${seed.commit.slice(0, 12)}.`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Turn a pointer slot into a private one before analyze opens or writes the
|
||||
* graph. No-op for a slot that already owns its graph or has no graph at all.
|
||||
* Caller holds the slot's index lock.
|
||||
*/
|
||||
export const ensurePrivateSharedGraph = async (slot: string, log: Log): Promise<void> => {
|
||||
const own = path.join(slot, LBUG_DIRECTORY);
|
||||
const pointed = resolveGraphPath(slot);
|
||||
if (pointed === own) return;
|
||||
const meta = await loadMeta(slot);
|
||||
if (!meta) return;
|
||||
if (!(await exists(own))) {
|
||||
// `lbug.new.<id>` is swept by the slot lock if this process dies mid-copy.
|
||||
const tmp = `${own}.new.${randomUUID()}`;
|
||||
const started = Date.now();
|
||||
await fs.copyFile(pointed, tmp, fsConstants.COPYFILE_FICLONE);
|
||||
await fs.rename(tmp, own);
|
||||
log(`Shared store: copied the shared graph for local changes in ${Date.now() - started}ms.`);
|
||||
}
|
||||
delete meta.graphPath;
|
||||
await saveMeta(slot, meta);
|
||||
};
|
||||
|
||||
const withPublishLock = async <T>(layout: SharedStoreLayout, fn: () => Promise<T>): Promise<T> => {
|
||||
const lockDir = path.join(layout.root, 'locks', 'publish');
|
||||
await fs.mkdir(lockDir, { recursive: true });
|
||||
const lock = await acquireIndexLock(lockDir);
|
||||
try {
|
||||
requireExclusiveIndexLock(lock, `Cannot acquire the shared-store publish lock at ${lockDir}.`);
|
||||
return await fn();
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* After a successful analyze of the flat slot: publish or reuse the commit
|
||||
* graph when the checkout is clean at HEAD, and make sure the registry points
|
||||
* at the slot. Caller holds the slot's index lock.
|
||||
*/
|
||||
export const publishSharedGraph = async (
|
||||
layout: SharedStoreLayout,
|
||||
repoPath: string,
|
||||
currentCommit: string,
|
||||
log: Log,
|
||||
): Promise<void> => {
|
||||
const slot = layout.checkoutSlot;
|
||||
const meta = await loadMeta(slot);
|
||||
if (!meta) return;
|
||||
const own = path.join(slot, LBUG_DIRECTORY);
|
||||
|
||||
const shareable =
|
||||
currentCommit !== '' &&
|
||||
meta.lastCommit === currentCommit &&
|
||||
!meta.incrementalInProgress &&
|
||||
!isWorkingTreeDirty(repoPath);
|
||||
if (shareable) {
|
||||
const target = commitGraphDir(layout, currentCommit, featureKeyOf(meta));
|
||||
const targetGraph = path.join(target, LBUG_DIRECTORY);
|
||||
const published = await withPublishLock(layout, async () => {
|
||||
if (await exists(targetGraph)) {
|
||||
await wipeLbugDbFiles(own);
|
||||
return true;
|
||||
}
|
||||
if (!(await exists(own)) || (await inspectLbugSidecars(own)).kind !== 'clean') return false;
|
||||
await fs.mkdir(layout.commitsDir, { recursive: true });
|
||||
const staging = path.join(layout.commitsDir, `.publish-${randomUUID()}`);
|
||||
await fs.mkdir(staging);
|
||||
const commitMeta: Record<string, unknown> = { ...meta };
|
||||
for (const field of CHECKOUT_FIELDS) delete commitMeta[field];
|
||||
try {
|
||||
await fs.rename(own, path.join(staging, LBUG_DIRECTORY));
|
||||
await fs.writeFile(path.join(staging, INDEX_METADATA_FILE), JSON.stringify(commitMeta));
|
||||
await fs.rename(staging, target);
|
||||
} catch (err) {
|
||||
// Put the graph back so the slot stays usable as a private index.
|
||||
await fs.rename(path.join(staging, LBUG_DIRECTORY), own).catch(() => {});
|
||||
await fs.rm(staging, { recursive: true, force: true }).catch(() => {});
|
||||
log(
|
||||
`Shared store: could not publish (${(err as Error).message}); keeping a private graph.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
log(`Shared store: published commit graph ${currentCommit.slice(0, 12)}.`);
|
||||
return true;
|
||||
});
|
||||
if (published) {
|
||||
meta.graphPath = targetGraph;
|
||||
await saveMeta(slot, meta);
|
||||
}
|
||||
} else if (meta.graphPath !== undefined && (await exists(own))) {
|
||||
delete meta.graphPath;
|
||||
await saveMeta(slot, meta);
|
||||
}
|
||||
|
||||
// The up-to-date fast path skips registration; a seeded or adopted checkout
|
||||
// must still end up registered at its slot.
|
||||
await registerRepo(repoPath, meta, { storagePath: slot });
|
||||
};
|
||||
|
|
@ -251,6 +251,31 @@ export const isGitRepo = (repoPath: string): boolean => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Number of commits from `ancestor` to HEAD, or null when `ancestor` is not
|
||||
* an ancestor of HEAD (or git fails). 0 means `ancestor` is HEAD.
|
||||
*/
|
||||
export const commitDistanceToHead = (repoPath: string, ancestor: string): number | null => {
|
||||
try {
|
||||
execFileSync('git', ['merge-base', '--is-ancestor', ancestor, 'HEAD'], {
|
||||
cwd: repoPath,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const count = Number(
|
||||
execFileSync('git', ['rev-list', '--count', `${ancestor}..HEAD`], {
|
||||
cwd: repoPath,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
encoding: 'utf8',
|
||||
}).trim(),
|
||||
);
|
||||
return Number.isInteger(count) ? count : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCurrentCommit = (repoPath: string): string => {
|
||||
try {
|
||||
return execSync('git rev-parse HEAD', {
|
||||
|
|
|
|||
266
gitnexus/test/integration/shared-store-analyze.test.ts
Normal file
266
gitnexus/test/integration/shared-store-analyze.test.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
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 { featureKeyOf, publishSharedGraph } from '../../src/core/shared-store-analyze.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
listRegisteredRepos,
|
||||
loadMeta,
|
||||
saveMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import type { RepoMeta } from '../../src/storage/repo-meta.js';
|
||||
import {
|
||||
commitGraphDir,
|
||||
resolveSharedStore,
|
||||
type SharedStoreLayout,
|
||||
} from '../../src/storage/shared-store.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
/**
|
||||
* #3352 — linked worktrees at one commit share one immutable commit graph in
|
||||
* the store under GITNEXUS_HOME, and a second worktree's analyze reuses it
|
||||
* without writing a graph.
|
||||
*/
|
||||
const git = (cwd: string, ...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
|
||||
const commitAll = (cwd: string, message: string): void => {
|
||||
git(cwd, 'add', '-A');
|
||||
git(cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', message);
|
||||
};
|
||||
|
||||
const layoutOf = (checkout: string): SharedStoreLayout => {
|
||||
const layout = resolveSharedStore(checkout);
|
||||
expect(layout).not.toBeNull();
|
||||
return layout as SharedStoreLayout;
|
||||
};
|
||||
|
||||
const listCommitDirs = async (layout: SharedStoreLayout): Promise<string[]> =>
|
||||
(await fs.readdir(layout.commitsDir).catch(() => [] as string[])).filter(
|
||||
(name) => !name.startsWith('.'),
|
||||
);
|
||||
|
||||
describe('shared sibling store analyze (#3352)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
let main: string;
|
||||
let wtA: string;
|
||||
let wtB: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-shared-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-shared-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
|
||||
const root = await fs.realpath(tmpRepo.dbPath);
|
||||
main = path.join(root, 'main');
|
||||
wtA = path.join(root, 'wt-a');
|
||||
wtB = path.join(root, 'wt-b');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
await fs.writeFile(
|
||||
path.join(main, 'a.ts'),
|
||||
'export function a() { return b(); }\nexport function b() { return 1; }\n',
|
||||
);
|
||||
commitAll(main, 'init');
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt-a', wtA);
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt-b', wtB);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('Covers AE1: three clean worktrees at one commit share one commit graph', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const results = [];
|
||||
for (const checkout of [main, wtA, wtB]) {
|
||||
results.push(await runFullAnalysis(checkout, {}, { onProgress: () => {} }));
|
||||
}
|
||||
|
||||
const layout = layoutOf(main);
|
||||
const commitDirs = await listCommitDirs(layout);
|
||||
expect(commitDirs).toHaveLength(1);
|
||||
const graph = path.join(layout.commitsDir, commitDirs[0], 'lbug');
|
||||
|
||||
for (const checkout of [main, wtA, wtB]) {
|
||||
const slot = layoutOf(checkout).checkoutSlot;
|
||||
expect(getStoragePaths(checkout, undefined, slot).lbugPath).toBe(graph);
|
||||
expect(existsSync(path.join(slot, 'lbug'))).toBe(false);
|
||||
// The pre-existing repository-local index location is never written.
|
||||
expect(existsSync(path.join(checkout, '.gitnexus', 'lbug'))).toBe(false);
|
||||
}
|
||||
// Siblings after the first reuse the published graph without a pipeline run.
|
||||
expect(results.map((r) => r.alreadyUpToDate === true)).toEqual([false, true, true]);
|
||||
|
||||
const registered = await listRegisteredRepos();
|
||||
for (const checkout of [main, wtA, wtB]) {
|
||||
const entry = registered.find((e) => e.path === checkout);
|
||||
expect(entry?.storagePath).toBe(layoutOf(checkout).checkoutSlot);
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
it('serves a sibling the same relative file paths from the shared graph', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(wtA, {}, { onProgress: () => {} });
|
||||
await runFullAnalysis(wtB, {}, { onProgress: () => {} });
|
||||
|
||||
const lbug = (await import('@ladybugdb/core')).default;
|
||||
const graph = getStoragePaths(wtB, undefined, layoutOf(wtB).checkoutSlot).lbugPath;
|
||||
const db = new lbug.Database(graph, 0, true, true);
|
||||
const conn = new lbug.Connection(db);
|
||||
const rows = (await (
|
||||
await conn.query('MATCH (f:File) RETURN f.filePath AS p ORDER BY p')
|
||||
).getAll()) as { p: string }[];
|
||||
await conn.close();
|
||||
await db.close();
|
||||
expect(rows.map((r) => r.p)).toEqual(['a.ts']);
|
||||
}, 180_000);
|
||||
|
||||
it('publishes a new commit graph when a clean worktree moves to a new commit', async () => {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(wtA, {}, { onProgress: () => {} });
|
||||
await fs.writeFile(path.join(wtA, 'c.ts'), 'export const c = 3;\n');
|
||||
commitAll(wtA, 'c');
|
||||
await runFullAnalysis(wtA, {}, { onProgress: () => {} });
|
||||
|
||||
const layout = layoutOf(wtA);
|
||||
const head = git(wtA, 'rev-parse', 'HEAD');
|
||||
const meta = await loadMeta(layout.checkoutSlot);
|
||||
expect(meta?.lastCommit).toBe(head);
|
||||
expect(meta?.graphPath).toBe(
|
||||
path.join(commitGraphDir(layout, head, featureKeyOf(meta as RepoMeta)), 'lbug'),
|
||||
);
|
||||
expect(await listCommitDirs(layout)).toHaveLength(2);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describe('featureKeyOf', () => {
|
||||
const base: RepoMeta = {
|
||||
repoPath: '/a',
|
||||
storagePath: '/a/.gitnexus',
|
||||
lastCommit: 'abc1234',
|
||||
indexedAt: '2026-01-01T00:00:00.000Z',
|
||||
schemaFingerprint: 'fp1',
|
||||
analysisFeatures: { x: 1 },
|
||||
};
|
||||
|
||||
it('ignores per-checkout and per-run fields', () => {
|
||||
expect(
|
||||
featureKeyOf({
|
||||
...base,
|
||||
repoPath: '/b',
|
||||
storagePath: '/b/.gitnexus',
|
||||
indexedAt: '2027-01-01T00:00:00.000Z',
|
||||
lastCommit: 'def5678',
|
||||
branch: 'feature',
|
||||
fileHashes: { 'a.ts': 'h' },
|
||||
stats: { nodes: 9 },
|
||||
}),
|
||||
).toBe(featureKeyOf(base));
|
||||
});
|
||||
|
||||
it('is independent of key order', () => {
|
||||
const reordered = Object.fromEntries(Object.entries(base).reverse()) as RepoMeta;
|
||||
expect(featureKeyOf(reordered)).toBe(featureKeyOf(base));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['schema fingerprint', { schemaFingerprint: 'fp2' }],
|
||||
['analysis features', { analysisFeatures: { x: 2 } }],
|
||||
['PDG layer', { pdg: {} as RepoMeta['pdg'] }],
|
||||
['content retention', { contentRetention: 'none' as const }],
|
||||
['embeddings present', { stats: { embeddings: 3 } }],
|
||||
])('changes with %s', (_label, delta) => {
|
||||
expect(featureKeyOf({ ...base, ...delta })).not.toBe(featureKeyOf(base));
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishSharedGraph race (#3352)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-shared-race-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-shared-race-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpRepo.cleanup();
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
const setup = async (): Promise<{ checkouts: string[]; head: string }> => {
|
||||
const root = await fs.realpath(tmpRepo.dbPath);
|
||||
const main = path.join(root, 'main');
|
||||
await fs.mkdir(main);
|
||||
git(main, 'init', '-q', '-b', 'main');
|
||||
await fs.writeFile(path.join(main, 'a.ts'), 'export const a = 1;\n');
|
||||
commitAll(main, 'init');
|
||||
const wt = path.join(root, 'wt');
|
||||
git(main, 'worktree', 'add', '-q', '-b', 'wt', wt);
|
||||
const head = git(main, 'rev-parse', 'HEAD');
|
||||
for (const checkout of [main, wt]) {
|
||||
const slot = layoutOf(checkout).checkoutSlot;
|
||||
await fs.mkdir(slot, { recursive: true });
|
||||
await fs.writeFile(path.join(slot, 'lbug'), `graph from ${checkout}`);
|
||||
await saveMeta(slot, {
|
||||
repoPath: checkout,
|
||||
storagePath: slot,
|
||||
lastCommit: head,
|
||||
indexedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return { checkouts: [main, wt], head };
|
||||
};
|
||||
|
||||
it('Covers AE6: two checkouts publishing one new commit produce exactly one graph', async () => {
|
||||
const { checkouts } = await setup();
|
||||
await Promise.all(
|
||||
checkouts.map((c) =>
|
||||
publishSharedGraph(layoutOf(c), c, git(c, 'rev-parse', 'HEAD'), () => {}),
|
||||
),
|
||||
);
|
||||
const layout = layoutOf(checkouts[0]);
|
||||
expect(await listCommitDirs(layout)).toHaveLength(1);
|
||||
const pointers = await Promise.all(
|
||||
checkouts.map(async (c) => (await loadMeta(layoutOf(c).checkoutSlot))?.graphPath),
|
||||
);
|
||||
expect(new Set(pointers).size).toBe(1);
|
||||
for (const c of checkouts) {
|
||||
expect(existsSync(path.join(layoutOf(c).checkoutSlot, 'lbug'))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a private graph whose sidecars are not consolidated', async () => {
|
||||
const { checkouts, head } = await setup();
|
||||
const [main] = checkouts;
|
||||
const slot = layoutOf(main).checkoutSlot;
|
||||
await fs.writeFile(path.join(slot, 'lbug.wal'), 'pending');
|
||||
await publishSharedGraph(layoutOf(main), main, head, () => {});
|
||||
expect(await listCommitDirs(layoutOf(main))).toEqual([]);
|
||||
expect(existsSync(path.join(slot, 'lbug'))).toBe(true);
|
||||
expect((await loadMeta(slot))?.graphPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a dirty checkout private', async () => {
|
||||
const { checkouts, head } = await setup();
|
||||
const [main] = checkouts;
|
||||
await fs.writeFile(path.join(main, 'a.ts'), 'export const a = 2;\n');
|
||||
await publishSharedGraph(layoutOf(main), main, head, () => {});
|
||||
expect(await listCommitDirs(layoutOf(main))).toEqual([]);
|
||||
expect(existsSync(path.join(layoutOf(main).checkoutSlot, 'lbug'))).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue