diff --git a/gitnexus/src/cli/clean.ts b/gitnexus/src/cli/clean.ts index 3086b73e5..91b6c8f76 100644 --- a/gitnexus/src/cli/clean.ts +++ b/gitnexus/src/cli/clean.ts @@ -17,7 +17,7 @@ import { type RegistryEntry, } from '../storage/repo-manager.js'; import { requireDeletableStoragePath, StorageDeletionError } from '../storage/storage-resolver.js'; -import { formatStaleSlotLine } from './stale-branch-format.js'; +import { formatSlotSize, formatStaleSlotLine } from './stale-branch-format.js'; import { listLocalHeads } from '../storage/git.js'; import { isContainedBranchDir, @@ -157,11 +157,6 @@ 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) { @@ -222,14 +217,14 @@ export const cleanCommand = async (options?: { } if (!options.force) { console.log( - t('clean.localIndex.preview', { path: legacy.dir, size: formatBytes(legacy.bytes) }), + t('clean.localIndex.preview', { path: legacy.dir, size: formatSlotSize(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) }), + t('clean.localIndex.deleted', { path: legacy.dir, size: formatSlotSize(legacy.bytes) }), ); return; } diff --git a/gitnexus/src/cli/status.ts b/gitnexus/src/cli/status.ts index c45d31084..15a6c1a4a 100644 --- a/gitnexus/src/cli/status.ts +++ b/gitnexus/src/cli/status.ts @@ -6,6 +6,7 @@ import { resolveGraphPath, storeRootOfCheckoutSlot } from '../storage/shared-store.js'; import { describeSharedGraph, findLegacyLocalIndex } from '../storage/shared-store-lifecycle.js'; +import { formatSlotSize } from './stale-branch-format.js'; import path from 'path'; import { getStoragePaths, @@ -413,7 +414,7 @@ export const statusCommand = async (options: StatusOptions = {}) => { console.log( t('status.legacyLocalIndex', { path: legacyLocalIndex.dir, - size: `${Math.ceil(legacyLocalIndex.bytes / 1024)} KB`, + size: formatSlotSize(legacyLocalIndex.bytes), }), ); } diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index d2c851234..826d2f3be 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -182,8 +182,8 @@ import { publishSharedGraph, resolveOptedInStore, seedSharedSlot, - withStoreLock, } from './shared-store-analyze.js'; +import { withStoreLock } from '../storage/shared-store-lifecycle.js'; import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js'; import { DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, diff --git a/gitnexus/src/core/shared-store-analyze.ts b/gitnexus/src/core/shared-store-analyze.ts index d269a0052..4a76d3ad5 100644 --- a/gitnexus/src/core/shared-store-analyze.ts +++ b/gitnexus/src/core/shared-store-analyze.ts @@ -20,7 +20,6 @@ import { constants as fsConstants } from 'fs'; 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, getRemoteUrl, isWorkingTreeDirty } from '../storage/git.js'; import { readRegistry, @@ -33,13 +32,14 @@ import { commitGraphDir, resolveGraphPath, resolveSharedStore, - SHARED_STORE_POINTER, sharedStoreLayout, storeRootOfCheckoutSlot, type SharedStoreLayout, } from '../storage/shared-store.js'; import { reclaimAfterSlotRemoval, + removeSharedStorePointer, + withStoreLock, writeSharedStorePointer, } from '../storage/shared-store-lifecycle.js'; import { GITNEXUS_DIR, INDEX_METADATA_FILE, LBUG_DIRECTORY } from '../storage/storage-constants.js'; @@ -101,6 +101,22 @@ const exists = (p: string): Promise => () => false, ); +/** + * Copy a graph file to `dest` via a unique `lbug.new.` temp (swept by the + * slot lock if this process dies mid-copy), cloning copy-on-write where the + * filesystem supports it. On failure the temp is removed and the error thrown. + */ +const cloneGraphFile = async (source: string, dest: string): Promise => { + const tmp = `${dest}.new.${randomUUID()}`; + try { + await fs.copyFile(source, tmp, fsConstants.COPYFILE_FICLONE); + await fs.rename(tmp, dest); + } catch (err) { + await fs.rm(tmp, { force: true }).catch(() => {}); + throw err; + } +}; + interface CommitGraph { dir: string; commit: string; @@ -175,13 +191,9 @@ const seedFromLocalIndex = async ( try { if (lock.lockFree || (await inspectLbugSidecars(sourceGraph)).kind !== 'clean') return false; await fs.mkdir(slot, { recursive: true }); - const own = path.join(slot, LBUG_DIRECTORY); - const tmp = `${own}.new.${randomUUID()}`; try { - await fs.copyFile(sourceGraph, tmp, fsConstants.COPYFILE_FICLONE); - await fs.rename(tmp, own); + await cloneGraphFile(sourceGraph, path.join(slot, LBUG_DIRECTORY)); } catch (err) { - await fs.rm(tmp, { force: true }).catch(() => {}); log(`Shared store: could not copy ${sourceGraph} (${(err as Error).message}).`); return false; } @@ -245,14 +257,10 @@ export const ensurePrivateSharedGraph = async (slot: string, log: Log): Promise< const meta = await loadMeta(slot); if (!meta) return true; if (!(await exists(own))) { - // `lbug.new.` is swept by the slot lock if this process dies mid-copy. - const tmp = `${own}.new.${randomUUID()}`; const started = Date.now(); try { - await fs.copyFile(pointed, tmp, fsConstants.COPYFILE_FICLONE); - await fs.rename(tmp, own); + await cloneGraphFile(pointed, own); } catch (err) { - await fs.rm(tmp, { force: true }).catch(() => {}); const reason = (err as NodeJS.ErrnoException).code ?? (err as Error).message; log(`Shared store: shared graph unavailable (${reason}); doing a full build.`); return false; @@ -346,8 +354,6 @@ export const publishSharedGraph = async ( await writeSharedStorePointer(repoPath, layout); }; -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 @@ -414,7 +420,7 @@ export const leaveSharedStore = async ( 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 removeSharedStorePointer(repoPath); 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 3b694b00a..994505d44 100644 --- a/gitnexus/src/storage/shared-store-lifecycle.ts +++ b/gitnexus/src/storage/shared-store-lifecycle.ts @@ -148,7 +148,7 @@ export const reclaimAfterSlotRemoval = async ( } }; -/** Graph directory a checkout slot reads, for reporting. */ +/** Whether a checkout slot reads a shared commit graph or its own private graph. */ export const describeSharedGraph = ( graphPath: string, storagePath: string, diff --git a/gitnexus/src/storage/storage-resolver.ts b/gitnexus/src/storage/storage-resolver.ts index 483ddc85e..5d796fbed 100644 --- a/gitnexus/src/storage/storage-resolver.ts +++ b/gitnexus/src/storage/storage-resolver.ts @@ -405,9 +405,8 @@ const inspectCodeIndexDB = async ( // owning one; `resolveGraphPath` only returns a path inside the same store's // commit graphs, else the slot's own graph. const graphPath = resolveGraphPath(resolved); - const target = graphPath === lbugPath ? lbugPath : graphPath; try { - await fsp.access(target); + await fsp.access(graphPath); return { present: true }; } catch (error) { const code = (error as NodeJS.ErrnoException)?.code; diff --git a/gitnexus/test/integration/shared-store-cache.test.ts b/gitnexus/test/integration/shared-store-cache.test.ts index cc1cda64f..78830a30c 100644 --- a/gitnexus/test/integration/shared-store-cache.test.ts +++ b/gitnexus/test/integration/shared-store-cache.test.ts @@ -3,7 +3,7 @@ import { existsSync } from 'fs'; import fs from 'fs/promises'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { withStoreLock } from '../../src/core/shared-store-analyze.js'; +import { withStoreLock } from '../../src/storage/shared-store-lifecycle.js'; import { loadMeta } from '../../src/storage/repo-manager.js'; import { resolveSharedStore,