diff --git a/gitnexus-claude-plugin/hooks/registry-query.cjs b/gitnexus-claude-plugin/hooks/registry-query.cjs index d3ac72fd7..86d8c3732 100644 --- a/gitnexus-claude-plugin/hooks/registry-query.cjs +++ b/gitnexus-claude-plugin/hooks/registry-query.cjs @@ -298,9 +298,10 @@ function resolveEntryStoragePath(entry) { return path.resolve(path.join(entry.path, GITNEXUS_DIR)); } +// A single path segment: `..repo-` is a legal slot name, `..` is not. function isDirectChild(parent, child) { const rel = path.relative(parent, child); - return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(path.sep); + return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep); } // Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a diff --git a/gitnexus-cursor-integration/hooks/registry-query.cjs b/gitnexus-cursor-integration/hooks/registry-query.cjs index d3ac72fd7..86d8c3732 100644 --- a/gitnexus-cursor-integration/hooks/registry-query.cjs +++ b/gitnexus-cursor-integration/hooks/registry-query.cjs @@ -298,9 +298,10 @@ function resolveEntryStoragePath(entry) { return path.resolve(path.join(entry.path, GITNEXUS_DIR)); } +// A single path segment: `..repo-` is a legal slot name, `..` is not. function isDirectChild(parent, child) { const rel = path.relative(parent, child); - return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(path.sep); + return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep); } // Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a diff --git a/gitnexus/hooks/claude/registry-query.cjs b/gitnexus/hooks/claude/registry-query.cjs index d3ac72fd7..86d8c3732 100644 --- a/gitnexus/hooks/claude/registry-query.cjs +++ b/gitnexus/hooks/claude/registry-query.cjs @@ -298,9 +298,10 @@ function resolveEntryStoragePath(entry) { return path.resolve(path.join(entry.path, GITNEXUS_DIR)); } +// A single path segment: `..repo-` is a legal slot name, `..` is not. function isDirectChild(parent, child) { const rel = path.relative(parent, child); - return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(path.sep); + return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep); } // Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a diff --git a/gitnexus/src/cli/clean.ts b/gitnexus/src/cli/clean.ts index 91b6c8f76..e9cc747ae 100644 --- a/gitnexus/src/cli/clean.ts +++ b/gitnexus/src/cli/clean.ts @@ -166,7 +166,7 @@ const reportReclaim = (result: ReclaimResult | null): void => { }; /** `clean --gc`: collect every shared store under GITNEXUS_HOME (#3352). */ -const collectSharedStores = async (): Promise => { +const collectSharedStores = async (force: boolean): Promise => { const storesDir = path.join(getGlobalDir(), STORES_DIR); const names = await fs.readdir(storesDir).catch(() => [] as string[]); if (names.length === 0) { @@ -175,9 +175,10 @@ const collectSharedStores = async (): Promise => { } for (const name of names) { const root = path.join(storesDir, name); - const result = await reclaimSharedStore(root, { gc: true }); + // Without --force this is a preview: same selection, nothing deleted. + const result = await reclaimSharedStore(root, { gc: true, dryRun: !force }); console.log( - t('clean.gc.store', { + t(force ? 'clean.gc.store' : 'clean.gc.preview', { path: root, members: result.droppedMembers.length, graphs: result.removed.length, @@ -186,6 +187,7 @@ const collectSharedStores = async (): Promise => { if (result.kept.length > 0) console.log(t('clean.shared.kept', { count: result.kept.length })); if (result.storeRemoved) console.log(t('clean.shared.storeRemoved', { path: root })); } + if (!force) console.log(`\n${t('common.runForceConfirm')}`); }; export const cleanCommand = async (options?: { @@ -198,7 +200,7 @@ export const cleanCommand = async (options?: { localIndex?: boolean; }) => { if (options?.gc) { - await collectSharedStores(); + await collectSharedStores(options.force === true); return; } diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 68f2a7bb5..32444ceac 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -71,6 +71,8 @@ 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.gc.preview': + 'Shared store {{path}}: would drop {{members}} checkout(s) and remove {{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.', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index e1ea742e4..60d1c654a 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -68,6 +68,7 @@ export const zhCN = { 'clean.shared.storeRemoved': '共享存储:已删除 {{path}}(没有剩余检出)。', 'clean.gc.none': '没有可回收的共享存储。', 'clean.gc.store': '共享存储 {{path}}:移除了 {{members}} 个检出,删除了 {{graphs}} 个提交图。', + 'clean.gc.preview': '共享存储 {{path}}:将移除 {{members}} 个检出并删除 {{graphs}} 个提交图。', 'clean.localIndex.none': '此检出中没有残留的本地索引。', 'clean.localIndex.preview': '将删除 {{path}} 处残留的本地索引({{size}})。共享索引不受影响。', 'clean.localIndex.deleted': '已删除 {{path}} 处残留的本地索引({{size}})。', diff --git a/gitnexus/src/cli/status.ts b/gitnexus/src/cli/status.ts index 15a6c1a4a..f33ed4603 100644 --- a/gitnexus/src/cli/status.ts +++ b/gitnexus/src/cli/status.ts @@ -358,7 +358,12 @@ export const statusCommand = async (options: StatusOptions = {}) => { const sharedStore = storeRoot ? { key: path.basename(storeRoot), - graph: describeSharedGraph(resolveGraphPath(repo.storagePath), repo.storagePath), + // A pinned branch index (`branches//lbug`) is always private; + // only the flat slot can point at a shared commit graph. + graph: + activeMeta === repo.meta + ? describeSharedGraph(resolveGraphPath(repo.storagePath), repo.storagePath) + : ('private' as const), commit: activeMeta.lastCommit, } : null; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 61e4f87fc..9bd1b1f25 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -1325,8 +1325,11 @@ export async function runFullAnalysis( // Leaving a store (`--no-share`, or sharing turned off): the up-to-date // path does not re-register, so point the registry at the new storage // before the old slot goes away. - await registerLeftStore(repoPath, writeTarget.storagePath); - if (slotToLeave) await leaveSharedStore(repoPath, slotToLeave, log); + if (slotToLeave) { + await leaveSharedStore(repoPath, slotToLeave, writeTarget.storagePath, log); + } else { + await registerLeftStore(repoPath, writeTarget.storagePath); + } } return result; } finally { diff --git a/gitnexus/src/core/shared-store-analyze.ts b/gitnexus/src/core/shared-store-analyze.ts index 5bf862a0d..a27c5f298 100644 --- a/gitnexus/src/core/shared-store-analyze.ts +++ b/gitnexus/src/core/shared-store-analyze.ts @@ -19,7 +19,7 @@ import { createHash, randomUUID } from 'crypto'; import { constants as fsConstants } from 'fs'; import fs from 'fs/promises'; import path from 'path'; -import { acquireIndexLock } from '../storage/index-lock.js'; +import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js'; import { commitDistanceToHead, getRemoteUrl, isWorkingTreeDirty } from '../storage/git.js'; import { canonicalizePath, @@ -448,16 +448,25 @@ export const optedInSlotToLeave = async (repoPath: string): Promise/.gitnexus`: delete its old store slot and reclaim what only that - * slot referenced. + * After a successful `--no-share` run: re-register the checkout at its new + * storage and delete its old store slot, both under the old slot's index lock + * so an analyze still running on that slot cannot re-register it afterwards + * or write into a deleted directory. Then reclaim what only that slot used. */ export const leaveSharedStore = async ( repoPath: string, previousSlot: string, + newStoragePath: string, log: Log, ): Promise => { - await fs.rm(previousSlot, { recursive: true, force: true }); + const lock = await acquireIndexLock(previousSlot); + try { + requireExclusiveIndexLock(lock, `Cannot acquire the index lock at ${previousSlot}.`); + await registerLeftStore(repoPath, newStoragePath); + await fs.rm(previousSlot, { recursive: true, force: true }); + } finally { + lock.release(); + } await removeSharedStorePointer(repoPath); await reclaimAfterSlotRemoval(previousSlot); log(`Shared store: left ${previousSlot}.`); diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index a30092586..8c5faf71b 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -8,7 +8,11 @@ * CORS is restricted to localhost, private/LAN networks, and the deployed site. */ -import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js'; +import { + acquireIndexLock, + requireExclusiveIndexLock, + type IndexLockHandle, +} from '../storage/index-lock.js'; import { ensurePrivateSharedGraph } from '../core/shared-store-analyze.js'; import { resolveGraphPath } from '../storage/shared-store.js'; import { reclaimAfterSlotRemoval } from '../storage/shared-store-lifecycle.js'; @@ -2130,22 +2134,20 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // Set inside withLbugDb, read after it closes (#2790). let partialRunError: string | undefined; let partialRunDetail: AnalyzeJobPartialOutcome | undefined; + // The in-memory repo lock only serializes this server; a CLI analyze + // in another process guards the slot with the index lock, so hold it + // for the whole embedding write, released in the finally below. + let slotLock: IndexLockHandle | undefined; try { + slotLock = await acquireIndexLock(storagePath); + requireExclusiveIndexLock( + slotLock, + `Cannot acquire the index lock at ${storagePath}; refusing an unlocked embedding run.`, + ); // Writes go to the slot's own graph; a shared-store checkout // reading an immutable commit graph (#3352) takes a private copy. - // The in-memory repo lock only serializes this server; a CLI - // analyze in another process guards the slot with the index lock. - const slotLock = await acquireIndexLock(storagePath); - try { - requireExclusiveIndexLock( - slotLock, - `Cannot acquire the index lock at ${storagePath}; refusing to copy the shared graph.`, - ); - if (!(await ensurePrivateSharedGraph(storagePath, () => {}))) { - throw new Error('The shared graph this repository reads is gone. Re-run analyze.'); - } - } finally { - slotLock.release(); + if (!(await ensurePrivateSharedGraph(storagePath, () => {}))) { + throw new Error('The shared graph this repository reads is gone. Re-run analyze.'); } const lbugPath = path.join(storagePath, LBUG_DIRECTORY); const ftsSession = await loadFtsSession(storagePath); @@ -2380,6 +2382,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => }); } } finally { + slotLock?.release(); clearTimeout(embedTimeout); releaseRepoLock(repoLockPath); } diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 258c13413..7cbcf7203 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -214,9 +214,11 @@ const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`; * across branches (#2106 KTD7). When `branch` is provided, both `lbugPath` * and `metaPath` are scoped under `branches//`. For the flat call * (no `branch`), `storagePath` and `lbugPath` remain byte-identical to the - * pre-multi-branch behavior (#2106); `metaPath`'s FILENAME changed from - * `meta.json` to `gitnexus.json` (PR #2363) — `saveMeta` keeps a `meta.json` - * mirror in sync for consumers that still read the legacy name. + * pre-multi-branch behavior (#2106), except that a shared-store checkout slot + * (#3352) returns the commit graph its metadata records (`resolveGraphPath`). + * `metaPath`'s FILENAME changed from `meta.json` to `gitnexus.json` + * (PR #2363) — `saveMeta` keeps a `meta.json` mirror in sync for consumers + * that still read the legacy name. * * Each branch slot has its own metadata file: * - Primary/flat: /.gitnexus/gitnexus.json diff --git a/gitnexus/src/storage/shared-store-lifecycle.ts b/gitnexus/src/storage/shared-store-lifecycle.ts index b02bd5da0..9102cd82d 100644 --- a/gitnexus/src/storage/shared-store-lifecycle.ts +++ b/gitnexus/src/storage/shared-store-lifecycle.ts @@ -95,7 +95,7 @@ const orphanMembers = async (slots: string[]): Promise> => { */ export const reclaimSharedStoreLocked = async ( storeRoot: string, - opts: { gc?: boolean } = {}, + opts: { gc?: boolean; dryRun?: boolean } = {}, ): Promise => { const result: ReclaimResult = { removed: [], kept: [], droppedMembers: [], storeRemoved: false }; const checkoutsDir = path.join(storeRoot, 'checkouts'); @@ -105,7 +105,7 @@ export const reclaimSharedStoreLocked = async ( if (opts.gc) { const orphans = await orphanMembers(slots); for (const slot of orphans) { - await fs.rm(slot, { recursive: true, force: true }); + if (!opts.dryRun) await fs.rm(slot, { recursive: true, force: true }); result.droppedMembers.push(slot); } slots = slots.filter((slot) => !orphans.has(slot)); @@ -118,6 +118,10 @@ export const reclaimSharedStoreLocked = async ( for (const name of await listDir(commitsDir)) { const dir = path.join(commitsDir, name); if (referenced.has(dir)) continue; + if (opts.dryRun) { + if (!name.startsWith('.')) result.removed.push(dir); + continue; + } try { await fs.rm(dir, { recursive: true, force: true }); if (!name.startsWith('.')) result.removed.push(dir); @@ -129,7 +133,7 @@ export const reclaimSharedStoreLocked = async ( } const remaining = (await listDir(checkoutsDir)).length + (await listDir(commitsDir)).length; - if (remaining === 0) { + if (remaining === 0 && !opts.dryRun) { // The lock directory lives inside the store; removing it while held is // safe on POSIX and is retried on the next reclaim elsewhere. await fs @@ -149,7 +153,7 @@ export const reclaimSharedStoreLocked = async ( */ export const reclaimSharedStore = async ( storeRoot: string, - opts: { gc?: boolean } = {}, + opts: { gc?: boolean; dryRun?: boolean } = {}, ): Promise => { if (!existsSync(storeRoot)) { return { removed: [], kept: [], droppedMembers: [], storeRemoved: false }; diff --git a/gitnexus/src/storage/shared-store.ts b/gitnexus/src/storage/shared-store.ts index cfd347641..d02276c39 100644 --- a/gitnexus/src/storage/shared-store.ts +++ b/gitnexus/src/storage/shared-store.ts @@ -196,9 +196,10 @@ export const commitGraphDir = ( return path.join(layout.commitsDir, `${commit}-${featureKey}`); }; +// A single path segment: `..repo-` is a legal slot name, `..` is not. const isDirectChild = (parent: string, child: string): boolean => { const rel = path.relative(parent, child); - return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(path.sep); + return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep); }; /** diff --git a/gitnexus/src/storage/storage-slot.ts b/gitnexus/src/storage/storage-slot.ts index 063e2fddd..0fbfe8318 100644 --- a/gitnexus/src/storage/storage-slot.ts +++ b/gitnexus/src/storage/storage-slot.ts @@ -3,7 +3,7 @@ * * 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. + * re-exports the two env-var names, so existing import sites are unchanged. */ import { createHash } from 'node:crypto'; @@ -25,7 +25,8 @@ const sanitizeSlotBasename = (value: string): string => { end--; } const candidate = sanitized.slice(0, end) || 'repository'; - return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate) + // Windows reserves device names with any extension too (`CON.txt`). + return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(candidate) ? `repository-${candidate}` : candidate; }; diff --git a/gitnexus/test/integration/shared-store-adoption.test.ts b/gitnexus/test/integration/shared-store-adoption.test.ts index a4ba0d41e..31e0532ef 100644 --- a/gitnexus/test/integration/shared-store-adoption.test.ts +++ b/gitnexus/test/integration/shared-store-adoption.test.ts @@ -152,6 +152,17 @@ describe('shared store adoption and reporting (#3352)', () => { expect((await statusJson(wt)).status).toBe('up-to-date'); }, 240_000); + it('status reports a pinned branch index as private even when the flat slot is shared', async () => { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await analyze(wt); + // The flat slot now holds `wt` and points at a shared commit graph. A + // different checked-out branch pinned with --branch gets its own index. + git(wt, 'checkout', '-q', '-b', 'pinned'); + await runFullAnalysis(wt, { branch: 'pinned' }, { onProgress: () => {} }); + const json = await statusJson(wt); + expect(json.sharedStore).toMatchObject({ graph: 'private' }); + }, 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'); diff --git a/gitnexus/test/integration/shared-store-analyze.test.ts b/gitnexus/test/integration/shared-store-analyze.test.ts index 99294f269..63aa23a8e 100644 --- a/gitnexus/test/integration/shared-store-analyze.test.ts +++ b/gitnexus/test/integration/shared-store-analyze.test.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'child_process'; import { existsSync } from 'fs'; import fs from 'fs/promises'; import path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { featureKeyOf, publishSharedGraph } from '../../src/core/shared-store-analyze.js'; import { getStoragePaths, @@ -18,6 +18,16 @@ import { } from '../../src/storage/shared-store.js'; import { createTempDir } from '../helpers/test-db.js'; +// These suites exercise sharing; an inherited opt-out would silently disable it. +const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE; +beforeAll(() => { + delete process.env.GITNEXUS_SHARED_STORE; +}); +afterAll(() => { + if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE; + else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch; +}); + /** * #3352 — linked worktrees at one commit share one immutable commit graph in * the store under GITNEXUS_HOME, and a second worktree's analyze reuses it diff --git a/gitnexus/test/integration/shared-store-cache.test.ts b/gitnexus/test/integration/shared-store-cache.test.ts index 78830a30c..3311f3e9f 100644 --- a/gitnexus/test/integration/shared-store-cache.test.ts +++ b/gitnexus/test/integration/shared-store-cache.test.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'child_process'; import { existsSync } from 'fs'; import fs from 'fs/promises'; import path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { withStoreLock } from '../../src/storage/shared-store-lifecycle.js'; import { loadMeta } from '../../src/storage/repo-manager.js'; import { @@ -12,6 +12,16 @@ import { } from '../../src/storage/shared-store.js'; import { createTempDir } from '../helpers/test-db.js'; +// These suites exercise sharing; an inherited opt-out would silently disable it. +const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE; +beforeAll(() => { + delete process.env.GITNEXUS_SHARED_STORE; +}); +afterAll(() => { + if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE; + else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch; +}); + /** * #3352 U5 — linked worktrees keep one parse cache and ParsedFile store per * shared store, and one member's prune never evicts chunks another member diff --git a/gitnexus/test/integration/shared-store-clean.test.ts b/gitnexus/test/integration/shared-store-clean.test.ts index cae8f59ac..cd68a01be 100644 --- a/gitnexus/test/integration/shared-store-clean.test.ts +++ b/gitnexus/test/integration/shared-store-clean.test.ts @@ -2,7 +2,7 @@ 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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js'; import { resolveSharedStore, @@ -15,6 +15,16 @@ import { } from '../../src/storage/shared-store-lifecycle.js'; import { createTempDir } from '../helpers/test-db.js'; +// These suites exercise sharing; an inherited opt-out would silently disable it. +const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE; +beforeAll(() => { + delete process.env.GITNEXUS_SHARED_STORE; +}); +afterAll(() => { + if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE; + else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch; +}); + /** * #3352 U6 — clean removes only what no remaining member references. */ @@ -123,6 +133,21 @@ describe('shared store clean (#3352)', () => { expect(await commitDirs(layout)).toHaveLength(1); }, 240_000); + it('clean --gc without --force previews and deletes nothing', async () => { + await analyze(wtA); + await fs.writeFile(path.join(wtB, 'b.ts'), 'export function beta() { return 2; }\n'); + commitAll(wtB, 'b'); + await analyze(wtB); + const slotB = layoutOf(wtB).checkoutSlot; + git(main, 'worktree', 'remove', '--force', wtB); + + const logs = await cleanIn(main, { gc: true }); + + expect(existsSync(slotB)).toBe(true); + expect(await commitDirs(layoutOf(wtA))).toHaveLength(2); + expect(logs.join('\n')).toMatch(/would drop 1 checkout\(s\) and remove 1 commit graph/); + }, 240_000); + it('clean --gc drops a deleted worktree and the graph only it referenced', async () => { await analyze(wtA); await fs.writeFile(path.join(wtB, 'b.ts'), 'export function beta() { return 2; }\n'); @@ -133,7 +158,7 @@ describe('shared store clean (#3352)', () => { expect(await commitDirs(layout)).toHaveLength(2); git(main, 'worktree', 'remove', '--force', wtB); - const logs = await cleanIn(main, { gc: true }); + const logs = await cleanIn(main, { gc: true, force: true }); expect(existsSync(slotB)).toBe(false); expect(await commitDirs(layout)).toHaveLength(1); diff --git a/gitnexus/test/integration/shared-store-clone-optin.test.ts b/gitnexus/test/integration/shared-store-clone-optin.test.ts index dc5a9190d..c82755f86 100644 --- a/gitnexus/test/integration/shared-store-clone-optin.test.ts +++ b/gitnexus/test/integration/shared-store-clone-optin.test.ts @@ -2,11 +2,21 @@ import { execFileSync } from 'child_process'; import { existsSync } from 'fs'; import fs from 'fs/promises'; import path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { getStoragePaths, listRegisteredRepos } from '../../src/storage/repo-manager.js'; import { resolveSharedStore, type SharedStoreLayout } from '../../src/storage/shared-store.js'; import { createTempDir } from '../helpers/test-db.js'; +// These suites exercise sharing; an inherited opt-out would silently disable it. +const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE; +beforeAll(() => { + delete process.env.GITNEXUS_SHARED_STORE; +}); +afterAll(() => { + if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE; + else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch; +}); + /** * #3352 U7 — an independent clone joins a shared store only by explicit * opt-in, and only when its remote matches the member it names. diff --git a/gitnexus/test/integration/shared-store-seed.test.ts b/gitnexus/test/integration/shared-store-seed.test.ts index 191e362ac..f543668cf 100644 --- a/gitnexus/test/integration/shared-store-seed.test.ts +++ b/gitnexus/test/integration/shared-store-seed.test.ts @@ -2,12 +2,22 @@ import { execFileSync } from 'child_process'; import { existsSync } from 'fs'; import fs from 'fs/promises'; import path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { ensurePrivateSharedGraph } from '../../src/core/shared-store-analyze.js'; import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js'; import { resolveSharedStore, type SharedStoreLayout } from '../../src/storage/shared-store.js'; import { createTempDir } from '../helpers/test-db.js'; +// These suites exercise sharing; an inherited opt-out would silently disable it. +const savedSharedStoreSwitch = process.env.GITNEXUS_SHARED_STORE; +beforeAll(() => { + delete process.env.GITNEXUS_SHARED_STORE; +}); +afterAll(() => { + if (savedSharedStoreSwitch === undefined) delete process.env.GITNEXUS_SHARED_STORE; + else process.env.GITNEXUS_SHARED_STORE = savedSharedStoreSwitch; +}); + /** * #3352 U4 — a checkout that needs its own graph is seeded from the nearest * commit graph and updated incrementally instead of rebuilt from scratch. @@ -274,7 +284,8 @@ describe('ensurePrivateSharedGraph', () => { const meta = await loadMeta(slot); delete meta?.graphPath; await saveMeta(slot, meta as NonNullable); + await fs.writeFile(path.join(slot, 'lbug'), 'private graph bytes'); expect(await ensurePrivateSharedGraph(slot, () => {})).toBe(true); - expect(existsSync(path.join(slot, 'lbug'))).toBe(false); + expect(await fs.readFile(path.join(slot, 'lbug'), 'utf-8')).toBe('private graph bytes'); }); }); diff --git a/gitnexus/test/unit/storage/shared-store.test.ts b/gitnexus/test/unit/storage/shared-store.test.ts index ec3565e67..53ed729b2 100644 --- a/gitnexus/test/unit/storage/shared-store.test.ts +++ b/gitnexus/test/unit/storage/shared-store.test.ts @@ -314,3 +314,29 @@ describe('resolveStoragePath store tier', () => { expect(resolveStoragePath(wts[0])).toBe(registered); }); }); + +describe('slot naming edge cases (#3352 review)', () => { + it.each(['CON.txt', 'com1.log', 'Lpt9.tar.gz'])( + 'prefixes a reserved Windows device name with an extension: %s', + (base) => { + expect(storageSlotName(path.join(path.sep, 'tmp', base))).toMatch( + new RegExp(`^repository-${base.replace('.', '\\.')}-[0-9a-f]{12}$`), + ); + }, + ); + + it('keeps an ordinary name that only starts like a device name', () => { + expect(storageSlotName(path.join(path.sep, 'tmp', 'console'))).toMatch(/^console-/); + }); + + it('accepts a checkout slot whose name starts with two dots', async () => { + const layout = sharedStoreLayout('..repo-0123456789ab', '/tmp/..checkout'); + const graph = path.join(commitGraphDir(layout, 'abc1234', 'deadbeef'), 'lbug'); + await fs.mkdir(layout.checkoutSlot, { recursive: true }); + await fs.writeFile( + path.join(layout.checkoutSlot, 'gitnexus.json'), + JSON.stringify({ graphPath: graph }), + ); + expect(resolveGraphPath(layout.checkoutSlot)).toBe(graph); + }); +}); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 519b51f09..29d44bf92 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -98,6 +98,14 @@ export default defineConfig({ 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-interrupted-checkpoint-recovery.test.ts', 'test/integration/lbug-readonly-init.test.ts', + // Shared sibling store (#3352): each file runs real analyses and opens + // the resulting LadybugDB graphs. + 'test/integration/shared-store-analyze.test.ts', + 'test/integration/shared-store-seed.test.ts', + 'test/integration/shared-store-cache.test.ts', + 'test/integration/shared-store-clean.test.ts', + 'test/integration/shared-store-clone-optin.test.ts', + 'test/integration/shared-store-adoption.test.ts', 'test/integration/analyze-wal-checkpoint-failure.test.ts', 'test/integration/lbug-non-ascii-path.test.ts', 'test/integration/lbug-conn-serialization.test.ts', @@ -202,6 +210,12 @@ export default defineConfig({ // Excluded here because it is included by `lbug-db` above; a file // in two projects would be collected (and run) twice. 'test/unit/incremental-index-extension-dml-gate.test.ts', + 'test/integration/shared-store-analyze.test.ts', + 'test/integration/shared-store-seed.test.ts', + 'test/integration/shared-store-cache.test.ts', + 'test/integration/shared-store-clean.test.ts', + 'test/integration/shared-store-clone-optin.test.ts', + 'test/integration/shared-store-adoption.test.ts', ], }, },