diff --git a/gitnexus-claude-plugin/hooks/registry-query.cjs b/gitnexus-claude-plugin/hooks/registry-query.cjs index 86d8c3732..f5b2a0576 100644 --- a/gitnexus-claude-plugin/hooks/registry-query.cjs +++ b/gitnexus-claude-plugin/hooks/registry-query.cjs @@ -321,9 +321,11 @@ function resolveGraphPath(storagePath, metadata) { const recorded = metadata && metadata.graphPath; if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own; const graph = path.resolve(recorded); + // Only a published `-` dir, never `.publish-*` staging. const valid = path.basename(graph) === LBUG_DIRECTORY && - isDirectChild(path.join(root, 'commits'), path.dirname(graph)); + isDirectChild(path.join(root, 'commits'), path.dirname(graph)) && + /^[0-9a-f]{7,64}-[0-9a-f]{8,64}$/.test(path.basename(path.dirname(graph))); return valid ? graph : own; } diff --git a/gitnexus-cursor-integration/hooks/registry-query.cjs b/gitnexus-cursor-integration/hooks/registry-query.cjs index 86d8c3732..f5b2a0576 100644 --- a/gitnexus-cursor-integration/hooks/registry-query.cjs +++ b/gitnexus-cursor-integration/hooks/registry-query.cjs @@ -321,9 +321,11 @@ function resolveGraphPath(storagePath, metadata) { const recorded = metadata && metadata.graphPath; if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own; const graph = path.resolve(recorded); + // Only a published `-` dir, never `.publish-*` staging. const valid = path.basename(graph) === LBUG_DIRECTORY && - isDirectChild(path.join(root, 'commits'), path.dirname(graph)); + isDirectChild(path.join(root, 'commits'), path.dirname(graph)) && + /^[0-9a-f]{7,64}-[0-9a-f]{8,64}$/.test(path.basename(path.dirname(graph))); return valid ? graph : own; } diff --git a/gitnexus/hooks/claude/registry-query.cjs b/gitnexus/hooks/claude/registry-query.cjs index 86d8c3732..f5b2a0576 100644 --- a/gitnexus/hooks/claude/registry-query.cjs +++ b/gitnexus/hooks/claude/registry-query.cjs @@ -321,9 +321,11 @@ function resolveGraphPath(storagePath, metadata) { const recorded = metadata && metadata.graphPath; if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own; const graph = path.resolve(recorded); + // Only a published `-` dir, never `.publish-*` staging. const valid = path.basename(graph) === LBUG_DIRECTORY && - isDirectChild(path.join(root, 'commits'), path.dirname(graph)); + isDirectChild(path.join(root, 'commits'), path.dirname(graph)) && + /^[0-9a-f]{7,64}-[0-9a-f]{8,64}$/.test(path.basename(path.dirname(graph))); return valid ? graph : own; } diff --git a/gitnexus/src/cli/clean.ts b/gitnexus/src/cli/clean.ts index 98b011a34..48649d93c 100644 --- a/gitnexus/src/cli/clean.ts +++ b/gitnexus/src/cli/clean.ts @@ -168,7 +168,12 @@ const reportReclaim = (result: ReclaimResult | null): void => { /** `clean --gc`: collect every shared store under GITNEXUS_HOME (#3352). */ const collectSharedStores = async (force: boolean): Promise => { const storesDir = path.join(getGlobalDir(), STORES_DIR); - const names = await fs.readdir(storesDir).catch(() => [] as string[]); + // Only a missing stores root means "nothing to collect"; an unreadable one + // must fail loudly rather than report success. + const names = await fs.readdir(storesDir).catch((err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') return [] as string[]; + throw err; + }); if (names.length === 0) { console.log(t('clean.gc.none')); return; diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 4a96cdd9f..33097d0b7 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -267,7 +267,8 @@ export const zhCN = { '即使已有其他路径使用相同 --name 别名,也注册该仓库。会使两个路径的 `-r ` 产生歧义;请用 -r 消除歧义。', 'help.option.analyze.shareWith': '加入同一仓库已注册工作树的共享索引存储(名称或路径);远程 URL 必须一致。之后的运行会记住此选择。', - 'help.option.analyze.noShare': '离开共享索引存储,重新索引到 /.gitnexus', + 'help.option.analyze.noShare': + '仅限已加入的克隆:离开共享索引存储,重新索引到 /.gitnexus(链接工作树始终共享;请改用 GITNEXUS_SHARED_STORE=off)', 'help.option.verbose': '启用详细输出', 'help.option.analyze.maxFileSize': '跳过大于该值的文件(KB)。默认:512。硬上限:32768(tree-sitter 限制)。', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index f1b42f9ec..f3fa5d8fa 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -150,7 +150,11 @@ program 'Join the shared index store of a registered worktree of the same repository ' + '(name or path); the remote URL must match. Remembered for later runs.', ) - .option('--no-share', 'Leave the shared index store and index into /.gitnexus again') + .option( + '--no-share', + 'Opted-in clones only: leave the shared index store and index into /.gitnexus again ' + + '(linked worktrees always share; set GITNEXUS_SHARED_STORE=off instead)', + ) .option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)') .option( '--max-file-size ', diff --git a/gitnexus/src/storage/shared-store-lifecycle.ts b/gitnexus/src/storage/shared-store-lifecycle.ts index 6913f1a24..5172aff27 100644 --- a/gitnexus/src/storage/shared-store-lifecycle.ts +++ b/gitnexus/src/storage/shared-store-lifecycle.ts @@ -12,7 +12,7 @@ import { existsSync } from 'fs'; import fs from 'fs/promises'; import path from 'path'; -import { acquireIndexLock, requireExclusiveIndexLock } from './index-lock.js'; +import { acquireIndexLock, requireExclusiveIndexLock, type IndexLockHandle } from './index-lock.js'; import { canonicalizePath, findRegistryEntryByRepoPath, @@ -94,9 +94,12 @@ const orphanMembers = async (slots: string[]): Promise> => { * (KTD7), and slot pointers written under the same lock are always counted. */ export const reclaimSharedStoreLocked = async ( - storeRoot: string, + storeRootInput: string, opts: { gc?: boolean; dryRun?: boolean } = {}, ): Promise => { + // Absolute, so commit dirs compare equal to the resolved graphPath parents + // even when GITNEXUS_HOME is relative. + const storeRoot = path.resolve(storeRootInput); const result: ReclaimResult = { removed: [], kept: [], droppedMembers: [], storeRemoved: false }; const checkoutsDir = path.join(storeRoot, 'checkouts'); const commitsDir = path.join(storeRoot, 'commits'); @@ -104,9 +107,31 @@ export const reclaimSharedStoreLocked = async ( let slots = (await listDir(checkoutsDir)).map((name) => path.join(checkoutsDir, name)); if (opts.gc) { const orphans = await orphanMembers(slots); - for (const slot of orphans) { - if (!opts.dryRun) await fs.rm(slot, { recursive: true, force: true }); - result.droppedMembers.push(slot); + for (const slot of [...orphans]) { + if (opts.dryRun) { + result.droppedMembers.push(slot); + continue; + } + // An analyze holds its slot's index lock until it has registered the + // checkout, so a slot that is seeded but not yet registered is busy, + // not orphaned. Judge and delete only a slot whose lock is free. + let lock: IndexLockHandle; + try { + lock = await acquireIndexLock(slot, { timeoutMs: 1 }); + } catch { + orphans.delete(slot); + continue; + } + try { + if (lock.lockFree || !(await orphanMembers([slot])).has(slot)) { + orphans.delete(slot); + continue; + } + await fs.rm(slot, { recursive: true, force: true }); + result.droppedMembers.push(slot); + } finally { + lock.release(); + } } slots = slots.filter((slot) => !orphans.has(slot)); } diff --git a/gitnexus/src/storage/shared-store.ts b/gitnexus/src/storage/shared-store.ts index 613aadbc5..9fd30cd86 100644 --- a/gitnexus/src/storage/shared-store.ts +++ b/gitnexus/src/storage/shared-store.ts @@ -50,6 +50,7 @@ const slotName = (p: string): string => { 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}$/; +const COMMIT_GRAPH_DIR_RE = /^[0-9a-f]{7,64}-[0-9a-f]{8,64}$/; export interface SharedStoreLayout { /** Store key: readable basename plus a hash of the canonical git common dir. */ @@ -267,8 +268,11 @@ export const resolveGraphPath = (storagePath: string): string => { if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own; const graph = path.resolve(recorded); const commitDir = path.dirname(graph); + // Only a published `-` dir, never `.publish-*` staging. const valid = - path.basename(graph) === LBUG_DIRECTORY && isDirectChild(path.join(root, 'commits'), commitDir); + path.basename(graph) === LBUG_DIRECTORY && + isDirectChild(path.join(root, 'commits'), commitDir) && + COMMIT_GRAPH_DIR_RE.test(path.basename(commitDir)); return valid ? graph : own; }; diff --git a/gitnexus/test/integration/shared-store-clean.test.ts b/gitnexus/test/integration/shared-store-clean.test.ts index 8c2e9ccc9..a9cc5b32a 100644 --- a/gitnexus/test/integration/shared-store-clean.test.ts +++ b/gitnexus/test/integration/shared-store-clean.test.ts @@ -240,6 +240,30 @@ describe('reclaimSharedStore', () => { expect(existsSync(outside)).toBe(true); }); + it('clean --gc keeps a slot whose index lock is held (analyze in progress)', async () => { + const slot = await member('busy-000000000000', { repoPath: '/nonexistent/checkout' }); + const { acquireIndexLock } = await import('../../src/storage/index-lock.js'); + const lock = await acquireIndexLock(slot); + try { + const result = await reclaimSharedStore(layout().root, { gc: true }); + expect(result.droppedMembers).toEqual([]); + expect(existsSync(slot)).toBe(true); + } finally { + lock.release(); + } + const after = await reclaimSharedStore(layout().root, { gc: true }); + expect(after.droppedMembers).toEqual([slot]); + }); + + it('counts references correctly when GITNEXUS_HOME is relative', async () => { + const referenced = await commitGraph('ddddddd-4444444444444444'); + await member('wt-000000000000', { graphPath: path.join(referenced, 'lbug') }); + const relativeRoot = path.relative(process.cwd(), layout().root); + const result = await reclaimSharedStore(relativeRoot); + expect(result.removed).toEqual([]); + expect(existsSync(referenced)).toBe(true); + }); + it('reports a graph it cannot delete instead of failing', async () => { const orphan = await commitGraph('ccccccc-3333333333333333'); await member('wt-000000000000', {}); diff --git a/gitnexus/test/unit/storage/shared-store.test.ts b/gitnexus/test/unit/storage/shared-store.test.ts index 9398eff58..939dad3a1 100644 --- a/gitnexus/test/unit/storage/shared-store.test.ts +++ b/gitnexus/test/unit/storage/shared-store.test.ts @@ -249,6 +249,10 @@ describe('resolveGraphPath', () => { ['outside GITNEXUS_HOME', () => '/etc/lbug'], ['a relative path', () => 'commits/abc1234-deadbeef/lbug'], ['a traversal', (l: SharedStoreLayout) => path.join(l.commitsDir, '..', '..', 'x', 'lbug')], + [ + 'in-progress publish staging', + (l: SharedStoreLayout) => path.join(l.commitsDir, '.publish-0f3c', 'lbug'), + ], [ 'a non-lbug file', (l: SharedStoreLayout) => path.join(l.commitsDir, 'abc1234-deadbeef', 'gitnexus.json'),