From 91c423d107afef1ff36f5863f3c9b861c91dd6eb Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 24 Sep 2026 06:32:40 +0000 Subject: [PATCH] feat(storage): resolve a shared checkout's graph and existing store slot (#3352) getStoragePaths reads a flat slot's recorded graphPath only for checkout slots under the stores directory; other paths keep /lbug with no I/O. A recorded path outside the store's commit graphs is ignored. resolveStoragePath falls back to an existing store slot for an unregistered checkout, so reads never move to an empty slot. Co-Authored-By: Claude Opus 5.5 (1M context) --- gitnexus/src/storage/repo-manager.ts | 5 +- gitnexus/src/storage/repo-meta.ts | 6 + gitnexus/src/storage/shared-store.ts | 51 ++++++++ gitnexus/src/storage/storage-resolver.ts | 7 ++ .../test/unit/storage/shared-store.test.ts | 117 ++++++++++++++++++ 5 files changed, 185 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 70508a1c5..e30b34e70 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -51,6 +51,7 @@ import { type RepoMeta, } from './repo-meta.js'; import { LBUG_DIRECTORY } from './storage-constants.js'; +import { resolveGraphPath } from './shared-store.js'; import { defaultStoragePath, ensureStoragePathWritable, @@ -234,7 +235,9 @@ export const getStoragePaths = ( const baseDir = branch ? path.join(storagePath, BRANCHES_DIR, branchSlug(branch)) : storagePath; return { storagePath, - lbugPath: path.join(baseDir, LBUG_DIRECTORY), + // Branch slots are always private; a flat shared-store slot may read a + // commit graph (#3352). + lbugPath: branch ? path.join(baseDir, LBUG_DIRECTORY) : resolveGraphPath(storagePath), metaPath: path.join(baseDir, INDEX_METADATA_FILE), // Branch-specific metadata file }; }; diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index db7b2a7a7..66540a164 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -102,6 +102,12 @@ export interface RepoMeta { repoPath: string; /** Complete index directory selected for this successful analysis. */ storagePath?: string; + /** + * Shared-store checkouts only (#3352): the graph this slot reads when it is + * not `/lbug` — a commit graph under the same store. Resolved + * and validated by `resolveGraphPath`. + */ + graphPath?: string; lastCommit: string; indexedAt: string; /** Missing on legacy metadata means the upstream-compatible `full` profile. */ diff --git a/gitnexus/src/storage/shared-store.ts b/gitnexus/src/storage/shared-store.ts index 0b76e13f5..8e778df53 100644 --- a/gitnexus/src/storage/shared-store.ts +++ b/gitnexus/src/storage/shared-store.ts @@ -23,6 +23,7 @@ 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 { slotNameForCanonicalPath, STORAGE_PATH_ENV, STORAGE_ROOT_ENV } from './storage-slot.js'; export const SHARED_STORE_ENV = 'GITNEXUS_SHARED_STORE'; @@ -162,3 +163,53 @@ export const commitGraphDir = ( } return path.join(layout.commitsDir, `${commit}-${featureKey}`); }; + +const isDirectChild = (parent: string, child: string): boolean => { + const rel = path.relative(parent, child); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(path.sep); +}; + +/** + * Store root for a checkout slot (`//checkouts/`), or null + * when `storagePath` is not a checkout slot. Pure path check — no I/O. + */ +export const storeRootOfCheckoutSlot = (storagePath: string): string | null => { + const storesRoot = path.resolve(getGlobalDir(), STORES_DIR); + const slot = path.resolve(storagePath); + const checkoutsDir = path.dirname(slot); + const root = path.dirname(checkoutsDir); + if (path.basename(checkoutsDir) !== 'checkouts') return null; + if (!isDirectChild(checkoutsDir, slot) || !isDirectChild(storesRoot, root)) return null; + return root; +}; + +/** + * The graph a flat storage slot reads. + * + * Non-shared storage is always `/lbug`, with no I/O. A shared + * checkout slot may record `graphPath` in its metadata, naming a commit graph + * in the same store; any other recorded value (outside the store, a sibling's + * private slot, unreadable metadata) falls back to the slot's own graph so a + * hand-edited file cannot redirect reads. + */ +export const resolveGraphPath = (storagePath: string): string => { + const own = path.join(storagePath, LBUG_DIRECTORY); + const root = storeRootOfCheckoutSlot(storagePath); + if (!root) return own; + let recorded: unknown; + try { + recorded = ( + JSON.parse(fs.readFileSync(path.join(storagePath, INDEX_METADATA_FILE), 'utf-8')) as { + graphPath?: unknown; + } + ).graphPath; + } catch { + return own; + } + if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own; + const graph = path.resolve(recorded); + const commitDir = path.dirname(graph); + const valid = + path.basename(graph) === LBUG_DIRECTORY && isDirectChild(path.join(root, 'commits'), commitDir); + return valid ? graph : own; +}; diff --git a/gitnexus/src/storage/storage-resolver.ts b/gitnexus/src/storage/storage-resolver.ts index 329759c36..a34c19306 100644 --- a/gitnexus/src/storage/storage-resolver.ts +++ b/gitnexus/src/storage/storage-resolver.ts @@ -9,6 +9,7 @@ import { LEGACY_METADATA_FILE, LBUG_DIRECTORY, } from './storage-constants.js'; +import { resolveSharedStore } from './shared-store.js'; import { slotNameForCanonicalPath, STORAGE_PATH_ENV, STORAGE_ROOT_ENV } from './storage-slot.js'; export { STORAGE_PATH_ENV, STORAGE_ROOT_ENV }; @@ -308,6 +309,12 @@ export const resolveStoragePath = (repoPath: string): string => { const registered = registeredStoragePath(resolvedRepoPath); if (registered) return registered; + // Shared sibling store (#3352): an unregistered checkout whose slot already + // exists (for example after the registry was reset). Checkouts only move INTO + // 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; + return defaultStoragePath(resolvedRepoPath); }; diff --git a/gitnexus/test/unit/storage/shared-store.test.ts b/gitnexus/test/unit/storage/shared-store.test.ts index da110ad97..360f3226f 100644 --- a/gitnexus/test/unit/storage/shared-store.test.ts +++ b/gitnexus/test/unit/storage/shared-store.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { commitGraphDir, isSharedStoreDisabled, + resolveGraphPath, resolveSharedStore, resolveSharedStoreKey, SHARED_STORE_ENV, @@ -15,8 +16,10 @@ import { import { STORAGE_PATH_ENV, STORAGE_ROOT_ENV, + resolveStoragePath, storageSlotName, } from '../../../src/storage/storage-resolver.js'; +import { getStoragePaths } from '../../../src/storage/repo-manager.js'; const temporaryPaths: string[] = []; const savedHome = process.env.GITNEXUS_HOME; @@ -196,3 +199,117 @@ describe('commitGraphDir', () => { expect(() => commitGraphDir(layout, commit, featureKey)).toThrow(/Invalid/); }); }); + +describe('resolveGraphPath', () => { + const writeSlotMeta = async (slot: string, meta: Record): Promise => { + await fs.mkdir(slot, { recursive: true }); + await fs.writeFile(path.join(slot, 'gitnexus.json'), JSON.stringify(meta)); + }; + + it('returns /lbug for non-shared storage without reading metadata', async () => { + const dir = await makeTempDir('gn-shared-local-'); + const storagePath = path.join(dir, '.gitnexus'); + await writeSlotMeta(storagePath, { graphPath: path.join(dir, 'elsewhere', 'lbug') }); + expect(resolveGraphPath(storagePath)).toBe(path.join(storagePath, 'lbug')); + }); + + it('returns the recorded commit graph for a shared checkout slot', async () => { + const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a'); + const graph = path.join(commitGraphDir(layout, 'abc1234', 'deadbeef'), 'lbug'); + await writeSlotMeta(layout.checkoutSlot, { graphPath: graph }); + expect(resolveGraphPath(layout.checkoutSlot)).toBe(graph); + }); + + it('returns the slot graph when no graphPath is recorded', async () => { + const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a'); + await writeSlotMeta(layout.checkoutSlot, { repoPath: '/tmp/checkout-a' }); + expect(resolveGraphPath(layout.checkoutSlot)).toBe(path.join(layout.checkoutSlot, 'lbug')); + }); + + it('returns the slot graph when metadata is missing or unparseable', async () => { + const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a'); + const own = path.join(layout.checkoutSlot, 'lbug'); + expect(resolveGraphPath(layout.checkoutSlot)).toBe(own); + await fs.mkdir(layout.checkoutSlot, { recursive: true }); + await fs.writeFile(path.join(layout.checkoutSlot, 'gitnexus.json'), '{not json'); + expect(resolveGraphPath(layout.checkoutSlot)).toBe(own); + }); + + it.each([ + [ + 'another store', + () => path.join(home, 'stores', 'other-000000000000', 'commits', 'abc1234-deadbeef', 'lbug'), + ], + [ + 'a sibling private slot', + (l: SharedStoreLayout) => path.join(l.checkoutsDir, 'sibling-000000000000', 'lbug'), + ], + ['outside GITNEXUS_HOME', () => '/etc/lbug'], + ['a relative path', () => 'commits/abc1234-deadbeef/lbug'], + ['a traversal', (l: SharedStoreLayout) => path.join(l.commitsDir, '..', '..', 'x', 'lbug')], + [ + 'a non-lbug file', + (l: SharedStoreLayout) => path.join(l.commitsDir, 'abc1234-deadbeef', 'gitnexus.json'), + ], + ])('ignores a recorded graphPath in %s', async (_label, graphPathFor) => { + const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a'); + await writeSlotMeta(layout.checkoutSlot, { graphPath: graphPathFor(layout) }); + expect(resolveGraphPath(layout.checkoutSlot)).toBe(path.join(layout.checkoutSlot, 'lbug')); + }); + + it('flows through getStoragePaths for the flat slot but not branch slots', async () => { + const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout-a'); + const graph = path.join(commitGraphDir(layout, 'abc1234', 'deadbeef'), 'lbug'); + await writeSlotMeta(layout.checkoutSlot, { graphPath: graph }); + expect(getStoragePaths('/tmp/checkout-a', undefined, layout.checkoutSlot).lbugPath).toBe(graph); + expect( + path.dirname(getStoragePaths('/tmp/checkout-a', 'feature', layout.checkoutSlot).lbugPath), + ).toMatch(/branches/); + }); +}); + +describe('resolveStoragePath store tier', () => { + const savedPath = process.env[STORAGE_PATH_ENV]; + const savedRoot = process.env[STORAGE_ROOT_ENV]; + const savedSwitch = process.env[SHARED_STORE_ENV]; + + beforeEach(() => { + delete process.env[STORAGE_PATH_ENV]; + delete process.env[STORAGE_ROOT_ENV]; + delete process.env[SHARED_STORE_ENV]; + }); + + afterEach(() => { + for (const [key, value] of [ + [STORAGE_PATH_ENV, savedPath], + [STORAGE_ROOT_ENV, savedRoot], + [SHARED_STORE_ENV, savedSwitch], + ] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it('keeps an unregistered worktree on local storage while its slot does not exist', async () => { + const { wts } = await makeRepo(['wt']); + expect(resolveStoragePath(wts[0])).toBe(path.join(wts[0], '.gitnexus')); + }); + + it('resolves an unregistered worktree to its existing store slot', async () => { + const { wts } = await makeRepo(['wt']); + const slot = layoutOf(wts[0]).checkoutSlot; + await fs.mkdir(slot, { recursive: true }); + expect(resolveStoragePath(wts[0])).toBe(slot); + }); + + it('prefers a registered storage path over an existing store slot', async () => { + const { wts } = await makeRepo(['wt']); + await fs.mkdir(layoutOf(wts[0]).checkoutSlot, { recursive: true }); + const registered = path.join(await makeTempDir('gn-shared-registered-'), 'index'); + await fs.writeFile( + path.join(home, 'registry.json'), + JSON.stringify([{ name: 'wt', path: wts[0], storagePath: registered }]), + ); + expect(resolveStoragePath(wts[0])).toBe(registered); + }); +});