feat(storage): adopt existing worktree indexes and report shared-store state (#3352)

A shared checkout gets <repo>/.gitnexus/store.json pointing at its store
slot; resolution follows it only when it names that checkout's own slot.
An existing local index seeds the slot and is left in place. status
(text and --json) and doctor report the store, whether the graph is
shared or private, and any leftover local index, which
clean --local-index removes while keeping the pointer. With
GITNEXUS_SHARED_STORE=off a previously shared checkout indexes into its
own .gitnexus again and never writes a commit graph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-24 10:12:41 +00:00
parent 8cdb05c22e
commit c86cca40b6
12 changed files with 447 additions and 14 deletions

View file

@ -36,8 +36,11 @@ import { t } from './i18n/index.js';
import { getGlobalDir } from '../storage/global-dir.js';
import { STORES_DIR } from '../storage/shared-store.js';
import {
findLegacyLocalIndex,
reclaimAfterSlotRemoval,
reclaimSharedStore,
removeLegacyLocalIndex,
removeSharedStorePointer,
type ReclaimResult,
} from '../storage/shared-store-lifecycle.js';
@ -154,6 +157,11 @@ const cleanStaleBranchSlots = async (force: boolean): Promise<void> => {
}
};
const formatBytes = (bytes: number): string =>
bytes >= 1024 * 1024
? `${(bytes / (1024 * 1024)).toFixed(1)} MB`
: `${Math.ceil(bytes / 1024)} KB`;
const reportReclaim = (result: ReclaimResult | null): void => {
if (!result) return;
if (result.removed.length > 0) {
@ -192,12 +200,40 @@ export const cleanCommand = async (options?: {
stale?: boolean;
branch?: string;
gc?: boolean;
localIndex?: boolean;
}) => {
if (options?.gc) {
await collectSharedStores();
return;
}
// --local-index: delete a pre-adoption index left in <checkout>/.gitnexus
// after the checkout moved into a shared store (#3352). Keeps the pointer.
if (options?.localIndex) {
const repo = await findRepo(process.cwd());
if (!repo) {
console.log(t('clean.notFoundHere'));
return;
}
const legacy = await findLegacyLocalIndex(repo.repoPath, repo.storagePath);
if (!legacy) {
console.log(t('clean.localIndex.none'));
return;
}
if (!options.force) {
console.log(
t('clean.localIndex.preview', { path: legacy.dir, size: formatBytes(legacy.bytes) }),
);
console.log(`\n${t('common.runForceConfirm')}`);
return;
}
await removeLegacyLocalIndex(repo.repoPath, repo.storagePath);
console.log(
t('clean.localIndex.deleted', { path: legacy.dir, size: formatBytes(legacy.bytes) }),
);
return;
}
// --stale: reclaim leftover per-branch slots whose recorded branch is not
// a live local head (#3331). Exclusive arm before --branch.
if (options?.stale) {
@ -386,7 +422,9 @@ export const cleanCommand = async (options?: {
await fs.rm(storagePath, { recursive: true, force: true });
await unregisterRepo(repo.repoPath);
console.log(t('common.deleted', { target: storagePath }));
reportReclaim(await reclaimAfterSlotRemoval(storagePath));
const reclaim = await reclaimAfterSlotRemoval(storagePath);
if (reclaim) await removeSharedStorePointer(repo.repoPath);
reportReclaim(reclaim);
} catch (err) {
logger.error({ err }, 'Failed to delete:');
}

View file

@ -1,4 +1,5 @@
import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js';
import { findLegacyLocalIndex } from '../storage/shared-store-lifecycle.js';
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
import { isHttpMode } from '../core/embeddings/http-client.js';
import {
@ -411,6 +412,14 @@ export const doctorCommand = async () => {
branches: entry?.branches,
});
const leftoverLines = leftoverBranchSlotDoctorLines(slots);
// A pre-adoption index left in <repo>/.gitnexus after this checkout moved
// into a shared store (#3352).
const legacy = await findLegacyLocalIndex(cwdRepo.repoPath, cwdRepo.storagePath);
if (legacy) {
leftoverLines.push(
t('status.legacyLocalIndex', { path: legacy.dir, size: formatSlotSize(legacy.bytes) }),
);
}
if (leftoverLines.length === 0) return;
console.log('');
for (const line of leftoverLines) {

View file

@ -28,6 +28,10 @@ export const en = {
'list.clusters': 'Clusters',
'list.processes': 'Processes',
'list.unknown': 'unknown',
'status.sharedStoreShared': 'Shared index: store {{key}}, shared graph for commit {{commit}}',
'status.sharedStorePrivate': 'Shared index: store {{key}}, private graph (local changes)',
'status.legacyLocalIndex':
'Leftover local index: {{path}} ({{size}}); remove it with `gitnexus clean --local-index --force`',
'status.notGitRepo': 'Not a git repository.',
'status.staleKuzu': 'Repository has a stale KuzuDB index from a previous version.',
'status.rebuildLadybug': 'Run: gitnexus analyze (rebuilds the index with LadybugDB)',
@ -67,6 +71,10 @@ export const en = {
'clean.gc.none': 'No shared stores to collect.',
'clean.gc.store':
'Shared store {{path}}: dropped {{members}} checkout(s), removed {{graphs}} commit graph(s).',
'clean.localIndex.none': 'No leftover local index in this checkout.',
'clean.localIndex.preview':
'This will delete the leftover local index at {{path}} ({{size}}). The shared index is not affected.',
'clean.localIndex.deleted': 'Deleted the leftover local index at {{path}} ({{size}}).',
'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}',
'clean.branchNotIndexed':
'No indexed branch named "{{branch}}" for this repository. Use `gitnexus clean --stale` to reclaim leftover branch indexes, or `gitnexus list` to see recorded names.',

View file

@ -29,6 +29,10 @@ export const zhCN = {
'list.clusters': '聚类',
'list.processes': '流程',
'list.unknown': 'unknown',
'status.sharedStoreShared': '共享索引:存储 {{key}},提交 {{commit}} 的共享图',
'status.sharedStorePrivate': '共享索引:存储 {{key}},私有图(有本地更改)',
'status.legacyLocalIndex':
'残留的本地索引:{{path}}({{size}});使用 `gitnexus clean --local-index --force` 删除',
'status.notGitRepo': '当前目录不是 git 仓库。',
'status.staleKuzu': '仓库包含旧版本遗留的 KuzuDB 索引。',
'status.rebuildLadybug': '运行:gitnexus analyze (使用 LadybugDB 重建索引)',
@ -64,6 +68,9 @@ export const zhCN = {
'clean.shared.storeRemoved': '共享存储:已删除 {{path}}(没有剩余检出)。',
'clean.gc.none': '没有可回收的共享存储。',
'clean.gc.store': '共享存储 {{path}}:移除了 {{members}} 个检出,删除了 {{graphs}} 个提交图。',
'clean.localIndex.none': '此检出中没有残留的本地索引。',
'clean.localIndex.preview': '将删除 {{path}} 处残留的本地索引({{size}})。共享索引不受影响。',
'clean.localIndex.deleted': '已删除 {{path}} 处残留的本地索引({{size}})。',
'clean.deleteCurrent': '将删除该仓库的 GitNexus 索引:{{repoName}}',
'clean.branchNotIndexed':
'该仓库没有名为 “{{branch}}” 的已索引分支。使用 `gitnexus clean --stale` 回收残留分支索引,或使用 `gitnexus list` 查看已记录名称。',

View file

@ -360,6 +360,10 @@ program
'--gc',
'Drop shared-store checkouts whose worktree is gone and delete commit graphs nothing references',
)
.option(
'--local-index',
'Delete the index left in <repo>/.gitnexus after this checkout moved into a shared store',
)
.option(
'--lbug-sidecars',
'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)',

View file

@ -4,6 +4,8 @@
* Shows the indexing status of the current repository.
*/
import { resolveGraphPath, storeRootOfCheckoutSlot } from '../storage/shared-store.js';
import { describeSharedGraph, findLegacyLocalIndex } from '../storage/shared-store-lifecycle.js';
import path from 'path';
import {
getStoragePaths,
@ -349,6 +351,17 @@ export const statusCommand = async (options: StatusOptions = {}) => {
!isWorkingTreeDirty(repo.repoPath));
const isUpToDate = metadataIsCurrent && contentIsCurrent;
// Shared sibling store (#3352): which graph this checkout reads, and any
// pre-adoption index still sitting in <repo>/.gitnexus.
const storeRoot = storeRootOfCheckoutSlot(repo.storagePath);
const sharedStore = storeRoot
? {
key: path.basename(storeRoot),
graph: describeSharedGraph(resolveGraphPath(repo.storagePath), repo.storagePath),
commit: activeMeta.lastCommit,
}
: null;
const legacyLocalIndex = await findLegacyLocalIndex(repo.repoPath, repo.storagePath);
if (options.json) {
console.log(
JSON.stringify({
@ -369,6 +382,10 @@ export const statusCommand = async (options: StatusOptions = {}) => {
runnerIdentity: currentRunnerIdentity,
},
contentDrift: describeContentDrift(contentDrift),
sharedStore,
legacyLocalIndex: legacyLocalIndex
? { path: legacyLocalIndex.dir, bytes: legacyLocalIndex.bytes }
: null,
status: isUpToDate ? 'up-to-date' : 'stale',
}),
);
@ -382,6 +399,24 @@ export const statusCommand = async (options: StatusOptions = {}) => {
console.log(t('status.workspaceIndexLabel', { primary: repo.meta.branch ?? '' }));
}
if (sharedStore) {
console.log(
sharedStore.graph === 'shared'
? t('status.sharedStoreShared', {
key: sharedStore.key,
commit: sharedStore.commit.slice(0, 7),
})
: t('status.sharedStorePrivate', { key: sharedStore.key }),
);
}
if (legacyLocalIndex) {
console.log(
t('status.legacyLocalIndex', {
path: legacyLocalIndex.dir,
size: `${Math.ceil(legacyLocalIndex.bytes / 1024)} KB`,
}),
);
}
console.log(`${t('status.indexed')}: ${new Date(activeMeta.indexedAt).toLocaleString()}`);
console.log(`${t('status.indexedCommit')}: ${activeMeta.lastCommit?.slice(0, 7)}`);
console.log(`${t('status.currentCommit')}: ${currentCommit?.slice(0, 7)}`);

View file

@ -165,8 +165,14 @@ import {
defaultStoragePath,
requireRegisteredStoragePath,
requireStoragePath,
resolveStoragePath,
} from '../storage/storage-resolver.js';
import { resolveSharedStore, type SharedStoreLayout } from '../storage/shared-store.js';
import {
isSharedStoreDisabled,
resolveSharedStore,
storeRootOfCheckoutSlot,
type SharedStoreLayout,
} from '../storage/shared-store.js';
import { LBUG_DIRECTORY } from '../storage/storage-constants.js';
import {
ensurePrivateSharedGraph,
@ -1130,11 +1136,16 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
const storageRequirements = options.force
? ANALYZE_FORCE_STORAGE_REQUIREMENTS
: ANALYZE_STORAGE_REQUIREMENTS;
const sharedStore = options.noShare
const sharingOff = options.noShare || isSharedStoreDisabled();
const sharedStore = sharingOff
? undefined
: (resolveSharedStore(repoPath) ?? (await resolveOptedInStore(repoPath, options.shareWith)));
// A checkout still registered in a store after sharing was turned off
// indexes into its own `.gitnexus` again; its slot is left for `clean --gc`.
const leavingStore =
!sharedStore && storeRootOfCheckoutSlot(resolveStoragePath(repoPath)) !== null;
const explicitStorage =
sharedStore?.checkoutSlot ?? (options.noShare ? defaultStoragePath(repoPath) : undefined);
sharedStore?.checkoutSlot ?? (leavingStore ? defaultStoragePath(repoPath) : undefined);
const storagePath = explicitStorage
? await requireRegisteredStoragePath(
{ path: repoPath, storagePath: explicitStorage },
@ -1169,9 +1180,11 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
: {};
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;
// Analyze always writes a store slot's own graph; a recorded `graphPath`
// (an immutable commit graph) only redirects readers.
const lbugPath = storeRootOfCheckoutSlot(storagePath)
? path.join(path.dirname(metaPath), LBUG_DIRECTORY)
: paths.lbugPath;
return {
storagePath,
repoHasGit,
@ -1308,7 +1321,7 @@ export async function runFullAnalysis(
if (flatShared) {
await publishSharedGraph(flatShared, repoPath, writeTarget.currentCommit, log);
} else if (slotToLeave) {
await leaveSharedStore(slotToLeave, log);
await leaveSharedStore(repoPath, slotToLeave, log);
}
return result;
} finally {

View file

@ -33,11 +33,15 @@ import {
commitGraphDir,
resolveGraphPath,
resolveSharedStore,
SHARED_STORE_POINTER,
sharedStoreLayout,
storeRootOfCheckoutSlot,
type SharedStoreLayout,
} from '../storage/shared-store.js';
import { reclaimAfterSlotRemoval } from '../storage/shared-store-lifecycle.js';
import {
reclaimAfterSlotRemoval,
writeSharedStorePointer,
} 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';
@ -339,6 +343,7 @@ export const publishSharedGraph = async (
// 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 });
await writeSharedStorePointer(repoPath, layout);
};
export { withStoreLock };
@ -403,8 +408,13 @@ export const optedInSlotToLeave = async (repoPath: string): Promise<string | und
* `<repo>/.gitnexus`: delete its old store slot and reclaim what only that
* slot referenced.
*/
export const leaveSharedStore = async (previousSlot: string, log: Log): Promise<void> => {
export const leaveSharedStore = async (
repoPath: string,
previousSlot: string,
log: Log,
): Promise<void> => {
await fs.rm(previousSlot, { recursive: true, force: true });
await fs.rm(path.join(repoPath, GITNEXUS_DIR, SHARED_STORE_POINTER), { force: true });
await reclaimAfterSlotRemoval(previousSlot);
log(`Shared store: left ${previousSlot}.`);
};

View file

@ -16,10 +16,11 @@ import { acquireIndexLock, requireExclusiveIndexLock } from './index-lock.js';
import { loadMeta } from './repo-meta.js';
import {
resolveSharedStore,
SHARED_STORE_POINTER,
storeRootOfCheckoutSlot,
type SharedStoreLayout,
} from './shared-store.js';
import { LBUG_DIRECTORY } from './storage-constants.js';
import { GITNEXUS_DIR, LBUG_DIRECTORY } from './storage-constants.js';
type StoreRoot = Pick<SharedStoreLayout, 'root'>;
@ -155,3 +156,78 @@ export const describeSharedGraph = (
path.resolve(graphPath) === path.join(path.resolve(storagePath), LBUG_DIRECTORY)
? 'private'
: 'shared';
/** Files a shared checkout keeps in `<checkout>/.gitnexus`; everything else there is legacy. */
const POINTER_DIR_KEEP = new Set([SHARED_STORE_POINTER, '.gitignore']);
/**
* Point `<checkout>/.gitnexus` at the checkout's store slot (#3352 R16). The
* directory's other contents — a pre-adoption index — are left untouched.
*/
export const writeSharedStorePointer = async (
checkoutPath: string,
layout: Pick<SharedStoreLayout, 'key' | 'checkoutSlot'>,
): Promise<void> => {
const dir = path.join(checkoutPath, GITNEXUS_DIR);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, SHARED_STORE_POINTER),
`${JSON.stringify({ version: 1, storeKey: layout.key, checkoutSlot: layout.checkoutSlot }, null, 2)}\n`,
);
await fs.writeFile(path.join(dir, '.gitignore'), '*\n', { flag: 'wx' }).catch(() => {});
};
/** Remove the pointer file (the directory stays if it holds anything else). */
export const removeSharedStorePointer = async (checkoutPath: string): Promise<void> => {
const dir = path.join(checkoutPath, GITNEXUS_DIR);
await fs.rm(path.join(dir, SHARED_STORE_POINTER), { force: true });
const rest = await listDir(dir);
if (rest.length === 1 && rest[0] === '.gitignore')
await fs.rm(dir, { recursive: true, force: true });
};
const sizeOf = async (target: string): Promise<number> => {
const stat = await fs.lstat(target).catch(() => null);
if (!stat) return 0;
if (!stat.isDirectory()) return stat.size;
let total = 0;
for (const name of await listDir(target)) total += await sizeOf(path.join(target, name));
return total;
};
export interface LegacyLocalIndex {
dir: string;
entries: string[];
bytes: number;
}
/**
* A pre-adoption index left in `<checkout>/.gitnexus` after the checkout moved
* into a shared store (#3352 R13). Null when the checkout is not shared or
* the directory holds only the pointer.
*/
export const findLegacyLocalIndex = async (
checkoutPath: string,
storagePath: string,
): Promise<LegacyLocalIndex | null> => {
if (!storeRootOfCheckoutSlot(storagePath)) return null;
const dir = path.join(checkoutPath, GITNEXUS_DIR);
const entries = (await listDir(dir)).filter((name) => !POINTER_DIR_KEEP.has(name));
if (entries.length === 0) return null;
let bytes = 0;
for (const name of entries) bytes += await sizeOf(path.join(dir, name));
return { dir, entries, bytes };
};
/** Delete a legacy local index, keeping the pointer. Returns what was removed. */
export const removeLegacyLocalIndex = async (
checkoutPath: string,
storagePath: string,
): Promise<LegacyLocalIndex | null> => {
const legacy = await findLegacyLocalIndex(checkoutPath, storagePath);
if (!legacy) return null;
for (const name of legacy.entries) {
await fs.rm(path.join(legacy.dir, name), { recursive: true, force: true });
}
return legacy;
};

View file

@ -23,11 +23,16 @@ import fs from 'fs';
import path from 'path';
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
import { getGlobalDir } from './global-dir.js';
import { INDEX_METADATA_FILE, LBUG_DIRECTORY } from './storage-constants.js';
import { GITNEXUS_DIR, INDEX_METADATA_FILE, LBUG_DIRECTORY } from './storage-constants.js';
import { slotNameForCanonicalPath, STORAGE_PATH_ENV, STORAGE_ROOT_ENV } from './storage-slot.js';
export const SHARED_STORE_ENV = 'GITNEXUS_SHARED_STORE';
export const STORES_DIR = 'stores';
/**
* Written into `<checkout>/.gitnexus/` when a checkout's index lives in a
* shared store, so tools that probe the checkout can find it (#3352 R16).
*/
export const SHARED_STORE_POINTER = 'store.json';
// Same canonical form as storage-resolver's `storageSlotName`, so a checkout's
// slot name does not depend on which spelling (symlink, 8.3 name) reached it.
@ -227,3 +232,25 @@ export const resolveGraphPath = (storagePath: string): string => {
path.basename(graph) === LBUG_DIRECTORY && isDirectChild(path.join(root, 'commits'), commitDir);
return valid ? graph : own;
};
/**
* The store slot named by `<checkout>/.gitnexus/store.json`, or null. The
* recorded slot must be this checkout's own slot under the stores directory,
* so a copied or hand-edited pointer cannot redirect reads to another index.
*/
export const readSharedStorePointer = (checkoutPath: string): string | null => {
let recorded: unknown;
try {
recorded = (
JSON.parse(
fs.readFileSync(path.join(checkoutPath, GITNEXUS_DIR, SHARED_STORE_POINTER), 'utf-8'),
) as { checkoutSlot?: unknown }
).checkoutSlot;
} catch {
return null;
}
if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return null;
const slot = path.resolve(recorded);
if (!storeRootOfCheckoutSlot(slot)) return null;
return path.basename(slot) === slotName(checkoutPath) ? slot : null;
};

View file

@ -9,13 +9,25 @@ import {
LEGACY_METADATA_FILE,
LBUG_DIRECTORY,
} from './storage-constants.js';
import { resolveGraphPath, resolveSharedStore } from './shared-store.js';
import {
readSharedStorePointer,
resolveGraphPath,
resolveSharedStore,
SHARED_STORE_POINTER,
} from './shared-store.js';
import { slotNameForCanonicalPath, STORAGE_PATH_ENV, STORAGE_ROOT_ENV } from './storage-slot.js';
export { STORAGE_PATH_ENV, STORAGE_ROOT_ENV };
/** File-backend lock sidecars (`index-lock.ts`). Not ownership data. */
const INDEX_LOCK_ARTIFACTS = new Set(['analyze.lock', 'analyze.lock.guard']);
const INDEX_LOCK_ARTIFACTS = new Set([
'analyze.lock',
'analyze.lock.guard',
// A shared-store checkout's pointer (#3352) and the ignore file beside it
// are not index data either.
SHARED_STORE_POINTER,
'.gitignore',
]);
export type StorageState =
| 'invalid_param'
@ -314,6 +326,8 @@ export const resolveStoragePath = (repoPath: string): string => {
// the store at analyze time; a read never switches to an empty slot.
const shared = resolveSharedStore(resolvedRepoPath);
if (shared && fs.existsSync(shared.checkoutSlot)) return shared.checkoutSlot;
const pointed = readSharedStorePointer(resolvedRepoPath);
if (pointed && fs.existsSync(pointed)) return pointed;
return defaultStoragePath(resolvedRepoPath);
};

View file

@ -0,0 +1,192 @@
import { execFileSync } from 'child_process';
import { existsSync } from 'fs';
import fs from 'fs/promises';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import {
readSharedStorePointer,
resolveSharedStore,
SHARED_STORE_ENV,
type SharedStoreLayout,
} from '../../src/storage/shared-store.js';
import { createTempDir } from '../helpers/test-db.js';
/**
* #3352 U8 — existing worktree indexes are adopted into the store without
* being deleted, and status/doctor/clean make the leftover visible and
* removable.
*/
const git = (cwd: string, ...args: string[]): string =>
execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
const layoutOf = (checkout: string): SharedStoreLayout => {
const layout = resolveSharedStore(checkout);
expect(layout).not.toBeNull();
return layout as SharedStoreLayout;
};
describe('shared store adoption and reporting (#3352)', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
let savedHome: string | undefined;
let savedSwitch: string | undefined;
let savedCwd: string;
let main: string;
let wt: string;
const analyze = async (checkout: string) => {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
return runFullAnalysis(checkout, {}, { onProgress: () => {} });
};
const runIn = async (checkout: string, fn: () => Promise<void>): Promise<string[]> => {
process.chdir(checkout);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
await fn();
return log.mock.calls.map((c) => String(c[0]));
} finally {
log.mockRestore();
process.chdir(savedCwd);
}
};
const statusJson = async (checkout: string) => {
const { statusCommand } = await import('../../src/cli/status.js');
const lines = await runIn(checkout, () => statusCommand({ json: true }));
return JSON.parse(lines[lines.length - 1]) as Record<string, unknown>;
};
/** Index `wt` the pre-store way, into its own `.gitnexus`. */
const legacyIndex = async (checkout: string): Promise<void> => {
process.env[SHARED_STORE_ENV] = 'off';
try {
await analyze(checkout);
} finally {
delete process.env[SHARED_STORE_ENV];
}
expect(existsSync(path.join(checkout, '.gitnexus', 'lbug'))).toBe(true);
};
beforeEach(async () => {
savedCwd = process.cwd();
tmpHome = await createTempDir('gitnexus-adopt-home-');
tmpRepo = await createTempDir('gitnexus-adopt-repo-');
savedHome = process.env.GITNEXUS_HOME;
savedSwitch = process.env[SHARED_STORE_ENV];
delete process.env[SHARED_STORE_ENV];
process.env.GITNEXUS_HOME = tmpHome.dbPath;
const root = await fs.realpath(tmpRepo.dbPath);
main = path.join(root, 'main');
await fs.mkdir(main);
git(main, 'init', '-q', '-b', 'main');
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);
});
afterEach(async () => {
process.chdir(savedCwd);
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
if (savedSwitch === undefined) delete process.env[SHARED_STORE_ENV];
else process.env[SHARED_STORE_ENV] = savedSwitch;
await tmpRepo.cleanup();
await tmpHome.cleanup();
});
it('Covers F4: adopts a worktree index into the store and leaves the old files', async () => {
await legacyIndex(wt);
const legacyGraph = await fs.readFile(path.join(wt, '.gitnexus', 'lbug'));
const result = await analyze(wt);
expect(result.alreadyUpToDate).toBe(true); // seeded from its own index
const layout = layoutOf(wt);
expect((await fs.readdir(layout.commitsDir)).filter((n) => !n.startsWith('.'))).toHaveLength(1);
expect(await fs.readFile(path.join(wt, '.gitnexus', 'lbug'))).toEqual(legacyGraph);
expect(readSharedStorePointer(wt)).toBe(layout.checkoutSlot);
expect((await listRegisteredRepos()).find((e) => e.path === wt)?.storagePath).toBe(
layout.checkoutSlot,
);
}, 240_000);
it('status reports the shared graph and the leftover index with its removal command', async () => {
await legacyIndex(wt);
await analyze(wt);
const json = await statusJson(wt);
expect(json.sharedStore).toEqual({
key: layoutOf(wt).key,
graph: 'shared',
commit: git(wt, 'rev-parse', 'HEAD'),
});
expect(json.legacyLocalIndex).toMatchObject({ path: path.join(wt, '.gitnexus') });
const { statusCommand } = await import('../../src/cli/status.js');
const text = (await runIn(wt, () => statusCommand({}))).join('\n');
expect(text).toMatch(/shared graph for commit/);
expect(text).toMatch(/gitnexus clean --local-index --force/);
}, 240_000);
it('clean --local-index deletes only the leftover files and keeps the pointer', async () => {
await legacyIndex(wt);
await analyze(wt);
const { cleanCommand } = await import('../../src/cli/clean.js');
await runIn(wt, () => cleanCommand({ localIndex: true }));
expect(existsSync(path.join(wt, '.gitnexus', 'lbug'))).toBe(true); // preview only
await runIn(wt, () => cleanCommand({ localIndex: true, force: true }));
expect((await fs.readdir(path.join(wt, '.gitnexus'))).sort()).toEqual([
'.gitignore',
'store.json',
]);
expect((await statusJson(wt)).legacyLocalIndex).toBeNull();
expect((await statusJson(wt)).status).toBe('up-to-date');
}, 240_000);
it('status reports a private graph for an edited worktree', async () => {
await analyze(wt);
await fs.writeFile(path.join(wt, 'a.ts'), 'export function alphaEdited() { return 1; }\n');
await analyze(wt);
expect((await statusJson(wt)).sharedStore).toMatchObject({ graph: 'private' });
}, 240_000);
it('indexes into .gitnexus with sharing turned off and never writes the commit graph', async () => {
await analyze(wt);
const layout = layoutOf(wt);
const commitDir = (await fs.readdir(layout.commitsDir)).find((n) => !n.startsWith('.'));
const commitGraph = path.join(layout.commitsDir, commitDir as string, 'lbug');
const before = await fs.readFile(commitGraph);
await fs.writeFile(path.join(wt, 'b.ts'), 'export function beta() { return 2; }\n');
process.env[SHARED_STORE_ENV] = 'off';
try {
await analyze(wt);
} finally {
delete process.env[SHARED_STORE_ENV];
}
expect(existsSync(path.join(wt, '.gitnexus', 'lbug'))).toBe(true);
expect((await listRegisteredRepos()).find((e) => e.path === wt)?.storagePath).toBe(
path.join(wt, '.gitnexus'),
);
expect(await fs.readFile(commitGraph)).toEqual(before);
}, 240_000);
it('rejects a pointer that names another checkout slot', async () => {
await analyze(wt);
const pointer = path.join(wt, '.gitnexus', 'store.json');
const other = layoutOf(main).checkoutSlot;
await fs.writeFile(pointer, JSON.stringify({ version: 1, checkoutSlot: other }));
expect(readSharedStorePointer(wt)).toBeNull();
await fs.writeFile(pointer, JSON.stringify({ version: 1, checkoutSlot: '/etc' }));
expect(readSharedStorePointer(wt)).toBeNull();
await fs.writeFile(pointer, 'not json');
expect(readSharedStorePointer(wt)).toBeNull();
}, 240_000);
});