diff --git a/gitnexus/src/storage/shared-store.ts b/gitnexus/src/storage/shared-store.ts new file mode 100644 index 000000000..0b76e13f5 --- /dev/null +++ b/gitnexus/src/storage/shared-store.ts @@ -0,0 +1,164 @@ +/** + * Shared sibling index store (#3352). + * + * Linked worktrees of one repository share one store under the GitNexus home: + * + * /stores// + * caches/ parse-cache + durable ParsedFile store + * commits/-/ one immutable graph per commit + settings + * checkouts// one checkout's metadata, membership, and + * private graph when it has local edits + * + * This module only resolves identity and names paths. It never creates, + * writes, or deletes anything. + * + * Membership is decided from the `.git` entry alone (no `git` subprocess), so + * the resolver stays cheap on hot paths (hooks, every CLI call). Only tree + * roots participate — a subdirectory of a checkout never resolves to a store, + * mirroring the `resolveRepoIdentityRoot` gate (#1259). A repository with no + * linked worktree keeps its repository-local `.gitnexus`. + */ + +import fs from 'fs'; +import path from 'path'; +import { stripWindowsLongPathPrefix } from '../lib/utils.js'; +import { getGlobalDir } from './global-dir.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'; + +// 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. +const slotName = (p: string): string => { + const resolved = path.resolve(p); + let canonical: string; + try { + canonical = fs.realpathSync.native(resolved); + } catch { + canonical = resolved; + } + return slotNameForCanonicalPath(stripWindowsLongPathPrefix(canonical)); +}; + +const DISABLED_VALUES = new Set(['off', '0', 'false', 'no']); +const COMMIT_RE = /^[0-9a-f]{7,64}$/; +const FEATURE_KEY_RE = /^[0-9a-f]{8,64}$/; + +export interface SharedStoreLayout { + /** Store key: readable basename plus a hash of the canonical git common dir. */ + key: string; + root: string; + cachesDir: string; + commitsDir: string; + checkoutsDir: string; + /** This checkout's slot — the registry `storagePath` for a shared checkout. */ + checkoutSlot: string; +} + +/** Sharing is off globally, or an explicit storage env override takes precedence. */ +export const isSharedStoreDisabled = (env: NodeJS.ProcessEnv = process.env): boolean => { + const value = env[SHARED_STORE_ENV]; + if (value !== undefined && DISABLED_VALUES.has(value.trim().toLowerCase())) return true; + return env[STORAGE_PATH_ENV] !== undefined || env[STORAGE_ROOT_ENV] !== undefined; +}; + +const hasLinkedWorktrees = (commonDir: string): boolean => { + try { + return fs.readdirSync(path.join(commonDir, 'worktrees')).length > 0; + } catch { + return false; + } +}; + +/** + * Resolve the git common dir for a tree root, or null when `checkoutPath` is + * not a tree root (non-git folder or an arbitrary subdirectory). + */ +const readCommonDir = (checkoutPath: string): string | null => { + const dotGit = path.join(checkoutPath, '.git'); + let stat: fs.Stats; + try { + stat = fs.statSync(dotGit); + } catch { + return null; + } + if (stat.isDirectory()) return dotGit; + if (!stat.isFile()) return null; + + // Linked worktree: `.git` is a file `gitdir: /worktrees/`, and + // that per-worktree dir holds a `commondir` file pointing back at . + let gitDir: string; + try { + const match = /^gitdir:\s*(.+?)\s*$/m.exec(fs.readFileSync(dotGit, 'utf-8')); + if (!match) return null; + gitDir = path.resolve(checkoutPath, match[1]); + } catch { + return null; + } + try { + const common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf-8').trim(); + return path.resolve(gitDir, common); + } catch { + // Submodules also use a `gitdir:` file but have no `commondir`; they are + // standalone repositories, not linked worktrees. + return null; + } +}; + +/** + * Store key for a checkout, or null when the checkout does not share. + * Main checkout and every linked worktree of one repository get the same key. + */ +export const resolveSharedStoreKey = ( + checkoutPath: string, + env: NodeJS.ProcessEnv = process.env, +): string | null => { + if (isSharedStoreDisabled(env)) return null; + const commonDir = readCommonDir(path.resolve(checkoutPath)); + if (!commonDir || !hasLinkedWorktrees(commonDir)) return null; + // `/.git` keys on `` for a readable name; a bare common dir + // (`repo.git`) keys on itself. Both hash the canonical absolute path. + const identity = path.basename(commonDir) === '.git' ? path.dirname(commonDir) : commonDir; + return slotName(identity); +}; + +/** Name every store path for `checkoutPath` under store `key`. */ +export const sharedStoreLayout = (key: string, checkoutPath: string): SharedStoreLayout => { + const storesRoot = path.join(getGlobalDir(), STORES_DIR); + const root = path.resolve(storesRoot, key); + if (path.dirname(root) !== path.resolve(storesRoot)) { + throw new Error(`Shared store key escapes the stores directory: ${key}`); + } + const checkoutsDir = path.join(root, 'checkouts'); + return { + key, + root, + cachesDir: path.join(root, 'caches'), + commitsDir: path.join(root, 'commits'), + checkoutsDir, + checkoutSlot: path.join(checkoutsDir, slotName(checkoutPath)), + }; +}; + +/** Resolve the full layout for a checkout, or null when it does not share. */ +export const resolveSharedStore = ( + checkoutPath: string, + env: NodeJS.ProcessEnv = process.env, +): SharedStoreLayout | null => { + const key = resolveSharedStoreKey(checkoutPath, env); + return key ? sharedStoreLayout(key, checkoutPath) : null; +}; + +/** Directory of the immutable graph for one commit and feature key. */ +export const commitGraphDir = ( + layout: SharedStoreLayout, + commit: string, + featureKey: string, +): string => { + if (!COMMIT_RE.test(commit)) throw new Error(`Invalid commit id for shared store: ${commit}`); + if (!FEATURE_KEY_RE.test(featureKey)) { + throw new Error(`Invalid feature key for shared store: ${featureKey}`); + } + return path.join(layout.commitsDir, `${commit}-${featureKey}`); +}; diff --git a/gitnexus/src/storage/storage-resolver.ts b/gitnexus/src/storage/storage-resolver.ts index b44dcc4ad..329759c36 100644 --- a/gitnexus/src/storage/storage-resolver.ts +++ b/gitnexus/src/storage/storage-resolver.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import fs from 'fs'; import fsp from 'fs/promises'; import path from 'path'; @@ -10,11 +9,9 @@ import { LEGACY_METADATA_FILE, LBUG_DIRECTORY, } from './storage-constants.js'; +import { slotNameForCanonicalPath, STORAGE_PATH_ENV, STORAGE_ROOT_ENV } from './storage-slot.js'; -export const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH'; -export const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT'; - -const STORAGE_SLOT_HASH_LENGTH = 12; +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']); @@ -211,37 +208,13 @@ const comparablePath = (value: string): string => { return process.platform === 'win32' ? canonical.toLowerCase() : canonical; }; -const sanitizeSlotBasename = (value: string): string => { - // Linear: a quantified `/[. ]+$/` on attacker-controlled basenames is - // js/polynomial-redos (CodeQL #1056). Cap first, then walk the tail once. - const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80); - let end = sanitized.length; - while (end > 0) { - const code = sanitized.charCodeAt(end - 1); - if (code !== 0x20 && code !== 0x2e) break; - end--; - } - const candidate = sanitized.slice(0, end) || 'repository'; - return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate) - ? `repository-${candidate}` - : candidate; -}; - /** * Stable slot name for one checkout inside a configured external storage root. * The canonical absolute path prevents symlink aliases from creating duplicate * slots, while the hash keeps same-basename repositories isolated. */ -export const storageSlotName = (repoPath: string): string => { - const canonical = canonicalRepoPath(repoPath); - const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical; - const basename = sanitizeSlotBasename(path.basename(canonical)); - const digest = createHash('sha256') - .update(identity) - .digest('hex') - .slice(0, STORAGE_SLOT_HASH_LENGTH); - return `${basename}-${digest}`; -}; +export const storageSlotName = (repoPath: string): string => + slotNameForCanonicalPath(canonicalRepoPath(repoPath)); export const defaultStoragePath = (repoPath: string): string => path.join(resolveRepoPath(repoPath), GITNEXUS_DIR); diff --git a/gitnexus/src/storage/storage-slot.ts b/gitnexus/src/storage/storage-slot.ts new file mode 100644 index 000000000..063e2fddd --- /dev/null +++ b/gitnexus/src/storage/storage-slot.ts @@ -0,0 +1,46 @@ +/** + * Leaf naming primitives for external index slots. + * + * Kept free of imports from `storage-resolver.ts` and `shared-store.ts` so both + * can use them without importing each other (#3352). `storage-resolver.ts` + * re-exports everything here, so existing import sites are unchanged. + */ + +import { createHash } from 'node:crypto'; +import path from 'path'; + +export const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH'; +export const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT'; + +const STORAGE_SLOT_HASH_LENGTH = 12; + +const sanitizeSlotBasename = (value: string): string => { + // Linear: a quantified `/[. ]+$/` on attacker-controlled basenames is + // js/polynomial-redos (CodeQL #1056). Cap first, then walk the tail once. + const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80); + let end = sanitized.length; + while (end > 0) { + const code = sanitized.charCodeAt(end - 1); + if (code !== 0x20 && code !== 0x2e) break; + end--; + } + const candidate = sanitized.slice(0, end) || 'repository'; + return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate) + ? `repository-${candidate}` + : candidate; +}; + +/** + * Slot name for an already-canonical absolute path: sanitized basename plus a + * short hash of the path (case-folded on Windows) so same-basename paths stay + * isolated. + */ +export const slotNameForCanonicalPath = (canonical: string): string => { + const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical; + const basename = sanitizeSlotBasename(path.basename(canonical)); + const digest = createHash('sha256') + .update(identity) + .digest('hex') + .slice(0, STORAGE_SLOT_HASH_LENGTH); + return `${basename}-${digest}`; +}; diff --git a/gitnexus/test/unit/storage/shared-store.test.ts b/gitnexus/test/unit/storage/shared-store.test.ts new file mode 100644 index 000000000..da110ad97 --- /dev/null +++ b/gitnexus/test/unit/storage/shared-store.test.ts @@ -0,0 +1,198 @@ +import { execFileSync } from 'child_process'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + commitGraphDir, + isSharedStoreDisabled, + resolveSharedStore, + resolveSharedStoreKey, + SHARED_STORE_ENV, + sharedStoreLayout, + type SharedStoreLayout, +} from '../../../src/storage/shared-store.js'; +import { + STORAGE_PATH_ENV, + STORAGE_ROOT_ENV, + storageSlotName, +} from '../../../src/storage/storage-resolver.js'; + +const temporaryPaths: string[] = []; +const savedHome = process.env.GITNEXUS_HOME; +let home: string; + +const makeTempDir = async (prefix: string): Promise => { + const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix))); + temporaryPaths.push(dir); + return dir; +}; + +const git = (cwd: string, ...args: string[]): void => { + execFileSync('git', args, { cwd, stdio: 'ignore' }); +}; + +/** A committed repo; `worktrees` names linked worktrees created beside it. */ +const makeRepo = async (worktrees: string[] = []): Promise<{ main: string; wts: string[] }> => { + const parent = await makeTempDir('gn-shared-store-'); + const main = path.join(parent, 'main'); + await fs.mkdir(main); + git(main, 'init', '-q', '-b', 'main'); + git( + main, + '-c', + 'user.email=t@t', + '-c', + 'user.name=t', + 'commit', + '-q', + '--allow-empty', + '-m', + 'init', + ); + const wts = worktrees.map((name) => { + const wt = path.join(parent, name); + git(main, 'worktree', 'add', '-q', '-b', name, wt); + return wt; + }); + return { main, wts }; +}; + +// Only these keys are read by isSharedStoreDisabled/resolveSharedStoreKey. +const cleanEnv = (): NodeJS.ProcessEnv => ({}); + +const layoutOf = (checkoutPath: string): SharedStoreLayout => { + const layout = resolveSharedStore(checkoutPath, cleanEnv()); + expect(layout).not.toBeNull(); + return layout as SharedStoreLayout; +}; + +beforeEach(async () => { + home = await makeTempDir('gn-shared-home-'); + process.env.GITNEXUS_HOME = home; +}); + +afterEach(async () => { + if (savedHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedHome; + await Promise.all( + temporaryPaths.splice(0).map((p) => fs.rm(p, { recursive: true, force: true })), + ); +}); + +describe('resolveSharedStoreKey', () => { + it('gives the main checkout and every linked worktree the same key', async () => { + const { main, wts } = await makeRepo(['wt-a', 'wt-b']); + const keys = [main, ...wts].map((p) => resolveSharedStoreKey(p, cleanEnv())); + expect(keys[0]).toMatch(/^main-[0-9a-f]{12}$/); + expect(new Set(keys).size).toBe(1); + }); + + it('keeps a repository without linked worktrees on local storage', async () => { + const { main } = await makeRepo(); + expect(resolveSharedStoreKey(main, cleanEnv())).toBeNull(); + }); + + it('gives two unrelated repos with the same basename different keys', async () => { + const a = await makeRepo(['wt']); + const b = await makeRepo(['wt']); + const keyA = resolveSharedStoreKey(a.main, cleanEnv()); + const keyB = resolveSharedStoreKey(b.main, cleanEnv()); + expect(keyA).not.toBeNull(); + expect(keyA).not.toBe(keyB); + }); + + it('does not share a subdirectory of a checkout', async () => { + const { main } = await makeRepo(['wt']); + const sub = path.join(main, 'pkg'); + await fs.mkdir(sub); + expect(resolveSharedStoreKey(sub, cleanEnv())).toBeNull(); + }); + + it('does not share a non-git folder', async () => { + const dir = await makeTempDir('gn-shared-nogit-'); + expect(resolveSharedStoreKey(dir, cleanEnv())).toBeNull(); + }); + + it('does not treat a gitdir file without commondir (submodule shape) as a worktree', async () => { + const dir = await makeTempDir('gn-shared-submodule-'); + const modules = path.join(dir, 'modules', 'sub'); + await fs.mkdir(modules, { recursive: true }); + await fs.writeFile(path.join(dir, '.git'), `gitdir: ${modules}\n`); + expect(resolveSharedStoreKey(dir, cleanEnv())).toBeNull(); + }); + + it.each([ + [{ [SHARED_STORE_ENV]: 'off' }], + [{ [SHARED_STORE_ENV]: 'FALSE' }], + [{ [SHARED_STORE_ENV]: '0' }], + [{ [STORAGE_PATH_ENV]: '/tmp/explicit-index' }], + [{ [STORAGE_ROOT_ENV]: '/tmp/index-root' }], + ])('returns null for every checkout when disabled by %o', async (env) => { + const { main, wts } = await makeRepo(['wt']); + expect(isSharedStoreDisabled(env)).toBe(true); + expect(resolveSharedStoreKey(main, env)).toBeNull(); + expect(resolveSharedStoreKey(wts[0], env)).toBeNull(); + }); + + it('treats an unrecognized switch value as enabled', () => { + expect(isSharedStoreDisabled({ [SHARED_STORE_ENV]: 'on' })).toBe(false); + }); +}); + +describe('sharedStoreLayout', () => { + it('places every area inside the store under GITNEXUS_HOME', async () => { + const { main, wts } = await makeRepo(['wt']); + const layout = layoutOf(wts[0]); + const root = path.join(home, 'stores', layout.key); + expect(layout).toEqual({ + key: resolveSharedStoreKey(main, cleanEnv()), + root, + cachesDir: path.join(root, 'caches'), + commitsDir: path.join(root, 'commits'), + checkoutsDir: path.join(root, 'checkouts'), + checkoutSlot: path.join(root, 'checkouts', storageSlotName(wts[0])), + }); + }); + + it('gives each checkout its own slot', async () => { + const { main, wts } = await makeRepo(['wt']); + const a = layoutOf(main); + const b = layoutOf(wts[0]); + expect(a.root).toBe(b.root); + expect(a.checkoutSlot).not.toBe(b.checkoutSlot); + }); + + it('maps a symlinked spelling of a worktree to the same slot', async () => { + const { wts } = await makeRepo(['wt']); + const link = path.join(await makeTempDir('gn-shared-link-'), 'alias'); + await fs.symlink(wts[0], link); + expect(layoutOf(link).checkoutSlot).toBe(layoutOf(wts[0]).checkoutSlot); + }); + + it.each(['..', '../escape', 'a/../../b'])( + 'rejects a key that escapes the stores dir: %s', + (key) => { + expect(() => sharedStoreLayout(key, '/tmp/x')).toThrow(/escapes the stores directory/); + }, + ); +}); + +describe('commitGraphDir', () => { + const layout = sharedStoreLayout('repo-0123456789ab', '/tmp/checkout'); + + it('names one directory per commit and feature key', () => { + expect(commitGraphDir(layout, 'abc1234', 'deadbeef')).toBe( + path.join(layout.commitsDir, 'abc1234-deadbeef'), + ); + }); + + it.each([ + ['../../x', 'deadbeef'], + ['ABC1234', 'deadbeef'], + ['abc1234', '../etc'], + ['abc1234', 'short'], + ])('rejects commit %s / feature key %s', (commit, featureKey) => { + expect(() => commitGraphDir(layout, commit, featureKey)).toThrow(/Invalid/); + }); +});