fix(hooks): prefix Windows device names with an extension in hook slot names (#3374)

Trigger: on Windows, storage-slot.ts sanitizeSlotBasename prefixes a
device-name basename that has an extension (`CON.txt` -> `repository-CON.txt`),
but the hook's copy in registry-query.cjs only matched the bare device name.
With GITNEXUS_STORAGE_ROOT set, a checkout named e.g. `con.txt` got a
different slot from the hook than from the CLI, so the hook could not see its
index.

Fix: mirror the platform branch (extension form on win32, bare form
elsewhere) in all four byte-identical registry-query.cjs copies, and point
their header comments at storage-slot.ts. Add a hook-vs-TS parity test over
device-name basenames on both stubbed platforms, and fix the byte-identity
test title to say four copies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-25 08:20:53 +00:00
parent 3e9035e322
commit ef4776c10c
5 changed files with 105 additions and 21 deletions

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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-<hash>`), 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);
});
});