diff --git a/gitnexus-claude-plugin/hooks/registry-query.cjs b/gitnexus-claude-plugin/hooks/registry-query.cjs index 0607602db..52abc3474 100644 --- a/gitnexus-claude-plugin/hooks/registry-query.cjs +++ b/gitnexus-claude-plugin/hooks/registry-query.cjs @@ -218,11 +218,11 @@ function branchSlug(rawRef) { return `${safe}-${hash}`; } -// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly +// Mirror gitnexus/src/storage/storage-slot.ts slotNameForCanonicalPath exactly // (sanitize + sha256 of the canonical repo path, 12-hex suffix). function sanitizeSlotBasename(value) { // Cap first, then walk the tail once — same order as - // gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS). + // gitnexus/src/storage/storage-slot.ts (avoids /[. ]+$/ ReDoS). const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80); let end = sanitized.length; while (end > 0) { @@ -231,9 +231,13 @@ function sanitizeSlotBasename(value) { 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; + // Windows also reserves device names with an extension (`CON.txt`); same + // platform branch as gitnexus/src/storage/storage-slot.ts. + const reserved = + process.platform === 'win32' + ? /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i + : /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + return reserved.test(candidate) ? `repository-${candidate}` : candidate; } function storageSlotName(repoPath) { diff --git a/gitnexus-cursor-integration/hooks/registry-query.cjs b/gitnexus-cursor-integration/hooks/registry-query.cjs index 0607602db..52abc3474 100644 --- a/gitnexus-cursor-integration/hooks/registry-query.cjs +++ b/gitnexus-cursor-integration/hooks/registry-query.cjs @@ -218,11 +218,11 @@ function branchSlug(rawRef) { return `${safe}-${hash}`; } -// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly +// Mirror gitnexus/src/storage/storage-slot.ts slotNameForCanonicalPath exactly // (sanitize + sha256 of the canonical repo path, 12-hex suffix). function sanitizeSlotBasename(value) { // Cap first, then walk the tail once — same order as - // gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS). + // gitnexus/src/storage/storage-slot.ts (avoids /[. ]+$/ ReDoS). const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80); let end = sanitized.length; while (end > 0) { @@ -231,9 +231,13 @@ function sanitizeSlotBasename(value) { 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; + // Windows also reserves device names with an extension (`CON.txt`); same + // platform branch as gitnexus/src/storage/storage-slot.ts. + const reserved = + process.platform === 'win32' + ? /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i + : /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + return reserved.test(candidate) ? `repository-${candidate}` : candidate; } function storageSlotName(repoPath) { diff --git a/gitnexus-factory-plugin/hooks/registry-query.cjs b/gitnexus-factory-plugin/hooks/registry-query.cjs index 0607602db..52abc3474 100644 --- a/gitnexus-factory-plugin/hooks/registry-query.cjs +++ b/gitnexus-factory-plugin/hooks/registry-query.cjs @@ -218,11 +218,11 @@ function branchSlug(rawRef) { return `${safe}-${hash}`; } -// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly +// Mirror gitnexus/src/storage/storage-slot.ts slotNameForCanonicalPath exactly // (sanitize + sha256 of the canonical repo path, 12-hex suffix). function sanitizeSlotBasename(value) { // Cap first, then walk the tail once — same order as - // gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS). + // gitnexus/src/storage/storage-slot.ts (avoids /[. ]+$/ ReDoS). const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80); let end = sanitized.length; while (end > 0) { @@ -231,9 +231,13 @@ function sanitizeSlotBasename(value) { 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; + // Windows also reserves device names with an extension (`CON.txt`); same + // platform branch as gitnexus/src/storage/storage-slot.ts. + const reserved = + process.platform === 'win32' + ? /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i + : /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + return reserved.test(candidate) ? `repository-${candidate}` : candidate; } function storageSlotName(repoPath) { diff --git a/gitnexus/hooks/claude/registry-query.cjs b/gitnexus/hooks/claude/registry-query.cjs index 0607602db..52abc3474 100644 --- a/gitnexus/hooks/claude/registry-query.cjs +++ b/gitnexus/hooks/claude/registry-query.cjs @@ -218,11 +218,11 @@ function branchSlug(rawRef) { return `${safe}-${hash}`; } -// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly +// Mirror gitnexus/src/storage/storage-slot.ts slotNameForCanonicalPath exactly // (sanitize + sha256 of the canonical repo path, 12-hex suffix). function sanitizeSlotBasename(value) { // Cap first, then walk the tail once — same order as - // gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS). + // gitnexus/src/storage/storage-slot.ts (avoids /[. ]+$/ ReDoS). const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80); let end = sanitized.length; while (end > 0) { @@ -231,9 +231,13 @@ function sanitizeSlotBasename(value) { 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; + // Windows also reserves device names with an extension (`CON.txt`); same + // platform branch as gitnexus/src/storage/storage-slot.ts. + const reserved = + process.platform === 'win32' + ? /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i + : /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + return reserved.test(candidate) ? `repository-${candidate}` : candidate; } function storageSlotName(repoPath) { diff --git a/gitnexus/test/unit/hooks-shared-store.test.ts b/gitnexus/test/unit/hooks-shared-store.test.ts index 186c72d9a..074767d3d 100644 --- a/gitnexus/test/unit/hooks-shared-store.test.ts +++ b/gitnexus/test/unit/hooks-shared-store.test.ts @@ -4,6 +4,7 @@ import os from 'os'; import path from 'path'; import { createRequire } from 'module'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { slotNameForCanonicalPath } from '../../src/storage/storage-slot.js'; /** * #3352 — the Claude hook resolves a shared-store checkout to the commit @@ -96,7 +97,7 @@ describe('registry-query shared store graph (#3352)', () => { fs.rmSync(tmp, { recursive: true, force: true }); }); - it('keeps the three hook copies byte-identical', () => { + it('keeps the four hook copies byte-identical', () => { const [primary, ...copies] = HOOK_COPIES.map((f) => fs.readFileSync(f, 'utf-8')); for (const copy of copies) expect(copy).toBe(primary); }); @@ -124,3 +125,70 @@ describe('registry-query shared store graph (#3352)', () => { ); }); }); + +/** + * #3374 — the hook's slot name must match `slotNameForCanonicalPath` for + * device-name basenames on both platform branches, or a GITNEXUS_STORAGE_ROOT + * index is invisible to the hook. The checkout itself is never created (the + * hook falls back to the resolved path); only the slot is written. A real + * Windows host cannot create the POSIX-branch slot (`CON.txt-`), so the + * stubbed rows run on POSIX hosts, which exercise both branches. + */ +describe.skipIf(process.platform === 'win32')('registry-query slot name parity (#3374)', () => { + const realPlatform = process.platform; + const savedHome = process.env.GITNEXUS_HOME; + const savedStoragePath = process.env.GITNEXUS_STORAGE_PATH; + const savedStorageRoot = process.env.GITNEXUS_STORAGE_ROOT; + let tmp: string; + + beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gn-hook-slot-'))); + process.env.GITNEXUS_HOME = path.join(tmp, 'home'); + process.env.GITNEXUS_STORAGE_ROOT = path.join(tmp, 'root'); + delete process.env.GITNEXUS_STORAGE_PATH; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true }); + if (savedHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedHome; + if (savedStoragePath === undefined) delete process.env.GITNEXUS_STORAGE_PATH; + else process.env.GITNEXUS_STORAGE_PATH = savedStoragePath; + if (savedStorageRoot === undefined) delete process.env.GITNEXUS_STORAGE_ROOT; + else process.env.GITNEXUS_STORAGE_ROOT = savedStorageRoot; + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + const basenames = [ + 'CON', + 'con.txt', + 'NUL.tar.gz', + 'COM1', + 'LPT9.log', + 'aux', + 'prn.', + 'normal', + 'CONSOLE', + 'com0.txt', + ]; + const rows = (['win32', 'linux'] as const).flatMap((platform) => + basenames.map((basename) => [platform, basename] as const), + ); + + it.each(rows)('on %s resolves the slot storage-slot.ts names for %s', (platform, basename) => { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + const checkout = path.join(tmp, 'repos', basename); + const slot = path.join(tmp, 'root', slotNameForCanonicalPath(checkout)); + fs.mkdirSync(slot, { recursive: true }); + fs.writeFileSync( + path.join(slot, 'gitnexus.json'), + JSON.stringify({ repoPath: checkout, storagePath: slot, lastCommit: 'abc' }), + ); + fs.mkdirSync(path.join(tmp, 'home'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, 'home', 'registry.json'), + JSON.stringify([{ name: basename, path: checkout, indexedAt: '', lastCommit: '' }]), + ); + expect(load(HOOK_COPIES[0]).findRegisteredRepo(checkout)?.storagePath).toBe(slot); + }); +});