From c86cca40b6b6112705eed7a195fe2f86d56c7400 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 24 Sep 2026 10:12:41 +0000 Subject: [PATCH] feat(storage): adopt existing worktree indexes and report shared-store state (#3352) A shared checkout gets /.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) --- gitnexus/src/cli/clean.ts | 40 +++- gitnexus/src/cli/doctor.ts | 9 + gitnexus/src/cli/i18n/en.ts | 8 + gitnexus/src/cli/i18n/zh-CN.ts | 7 + gitnexus/src/cli/index.ts | 4 + gitnexus/src/cli/status.ts | 35 ++++ gitnexus/src/core/run-analyze.ts | 27 ++- gitnexus/src/core/shared-store-analyze.ts | 14 +- .../src/storage/shared-store-lifecycle.ts | 78 ++++++- gitnexus/src/storage/shared-store.ts | 29 ++- gitnexus/src/storage/storage-resolver.ts | 18 +- .../integration/shared-store-adoption.test.ts | 192 ++++++++++++++++++ 12 files changed, 447 insertions(+), 14 deletions(-) create mode 100644 gitnexus/test/integration/shared-store-adoption.test.ts diff --git a/gitnexus/src/cli/clean.ts b/gitnexus/src/cli/clean.ts index 51b57db23..3086b73e5 100644 --- a/gitnexus/src/cli/clean.ts +++ b/gitnexus/src/cli/clean.ts @@ -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 => { } }; +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 /.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:'); } diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index cad7a9554..1681c0bc4 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -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 /.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) { diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index ea08053ce..0c4c267f0 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -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.', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index c18f106e7..05a03ebcc 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -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` 查看已记录名称。', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 428fba64c..21cbb016a 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -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 /.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)', diff --git a/gitnexus/src/cli/status.ts b/gitnexus/src/cli/status.ts index de69c513c..c45d31084 100644 --- a/gitnexus/src/cli/status.ts +++ b/gitnexus/src/cli/status.ts @@ -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 /.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)}`); diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 82386be31..d2c851234 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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 { diff --git a/gitnexus/src/core/shared-store-analyze.ts b/gitnexus/src/core/shared-store-analyze.ts index 1ba8ea52b..d269a0052 100644 --- a/gitnexus/src/core/shared-store-analyze.ts +++ b/gitnexus/src/core/shared-store-analyze.ts @@ -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/.gitnexus`: delete its old store slot and reclaim what only that * slot referenced. */ -export const leaveSharedStore = async (previousSlot: string, log: Log): Promise => { +export const leaveSharedStore = async ( + repoPath: string, + previousSlot: string, + log: Log, +): Promise => { 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}.`); }; diff --git a/gitnexus/src/storage/shared-store-lifecycle.ts b/gitnexus/src/storage/shared-store-lifecycle.ts index c0d8c6131..3b694b00a 100644 --- a/gitnexus/src/storage/shared-store-lifecycle.ts +++ b/gitnexus/src/storage/shared-store-lifecycle.ts @@ -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; @@ -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 `/.gitnexus`; everything else there is legacy. */ +const POINTER_DIR_KEEP = new Set([SHARED_STORE_POINTER, '.gitignore']); + +/** + * Point `/.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, +): Promise => { + 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 => { + 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 => { + 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 `/.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 => { + 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 => { + 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; +}; diff --git a/gitnexus/src/storage/shared-store.ts b/gitnexus/src/storage/shared-store.ts index 0391c4c15..6f6a1f7eb 100644 --- a/gitnexus/src/storage/shared-store.ts +++ b/gitnexus/src/storage/shared-store.ts @@ -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 `/.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 `/.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; +}; diff --git a/gitnexus/src/storage/storage-resolver.ts b/gitnexus/src/storage/storage-resolver.ts index 031d8b909..483ddc85e 100644 --- a/gitnexus/src/storage/storage-resolver.ts +++ b/gitnexus/src/storage/storage-resolver.ts @@ -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); }; diff --git a/gitnexus/test/integration/shared-store-adoption.test.ts b/gitnexus/test/integration/shared-store-adoption.test.ts new file mode 100644 index 000000000..ae7bd1f0f --- /dev/null +++ b/gitnexus/test/integration/shared-store-adoption.test.ts @@ -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>; + let tmpRepo: Awaited>; + 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): Promise => { + 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; + }; + + /** Index `wt` the pre-store way, into its own `.gitnexus`. */ + const legacyIndex = async (checkout: string): Promise => { + 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); +});