diff --git a/src/commands/hook.ts b/src/commands/hook.ts index 9f28ed2c..a3ec3e93 100644 --- a/src/commands/hook.ts +++ b/src/commands/hook.ts @@ -111,6 +111,22 @@ function findGitRoot(cwd: string): string { return resolveGitRoot(cwd); } +/** + * Normalize hook cwd values across shell environments. + * On Windows Git Bash/MSYS may pass "/c/path" which Node cannot use as cwd. + */ +export function normalizeHookCwd(cwd: string, platform: NodeJS.Platform = process.platform): string { + if (platform !== 'win32') return cwd; + + const msysDrivePath = /^\/([a-zA-Z])(?:\/(.*))?$/; + const match = cwd.match(msysDrivePath); + if (!match) return cwd; + + const drive = match[1].toUpperCase(); + const rest = match[2] ?? ''; + return rest.length > 0 ? `${drive}:/${rest}` : `${drive}:/`; +} + /** Stderr labels per event for user-facing messages. */ const STDERR_LABELS: Record = { 'session:start': { success: 'Memory loaded.', prefix: 'Memory loaded' }, @@ -131,7 +147,8 @@ export async function hookCommand(eventName: string, _logger?: ILogger): Promise try { const input = await readStdin(); - const cwd = input.cwd ?? process.cwd(); + const cwd = normalizeHookCwd(input.cwd ?? process.cwd()); + const normalizedInput: IHookInput = { ...input, cwd }; const repoRoot = findGitRoot(cwd); // Load .env from repository root for API keys (e.g., ANTHROPIC_API_KEY) @@ -147,7 +164,7 @@ export async function hookCommand(eventName: string, _logger?: ILogger): Promise const container = createContainer({ scope: `hook:${eventName}` }); const { eventBus } = container.cradle; - const event = buildEvent(eventType, input); + const event = buildEvent(eventType, normalizedInput); const results = await eventBus.emit(event); // Successful handler output → stdout (Claude context) diff --git a/src/hooks/commit-msg.ts b/src/hooks/commit-msg.ts index 6ca86942..1fc38d56 100644 --- a/src/hooks/commit-msg.ts +++ b/src/hooks/commit-msg.ts @@ -17,7 +17,7 @@ import { execFileSync } from 'child_process'; const HOOK_FINGERPRINT_PREFIX = '# git-mem:commit-msg'; /** Full fingerprint with version — used for upgrade detection. */ -const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v4`; +const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v6`; /** * The shell hook script. @@ -44,13 +44,16 @@ head -1 "$COMMIT_MSG_FILE" | grep -qiE "^(fixup|squash|amend)! " && exit 0 # Skip revert commits (auto-generated) head -1 "$COMMIT_MSG_FILE" | grep -qiE '^Revert "' && exit 0 +# Resolve repository root for hook context. +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + # Escape values for safe JSON inclusion (handles quotes, backslashes) COMMIT_MSG_FILE_ESC=$(printf '%s' "$COMMIT_MSG_FILE" | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g') -CWD_ESC=$(pwd | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g') +REPO_ROOT_ESC=$(printf '%s' "$REPO_ROOT" | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g') # Run git-mem commit-msg analyzer # Pass commit message file path via JSON stdin -echo "{\\"commit_msg_path\\": \\"$COMMIT_MSG_FILE_ESC\\", \\"cwd\\": \\"$CWD_ESC\\"}" | \\ +echo "{\\"commit_msg_path\\": \\"$COMMIT_MSG_FILE_ESC\\", \\"cwd\\": \\"$REPO_ROOT_ESC\\"}" | \\ git-mem hook commit-msg 2>/dev/null || true exit 0 diff --git a/tests/unit/commands/hook.test.ts b/tests/unit/commands/hook.test.ts index 0e63b344..b5459ebd 100644 --- a/tests/unit/commands/hook.test.ts +++ b/tests/unit/commands/hook.test.ts @@ -8,7 +8,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { EVENT_MAP, isEventEnabled, buildEvent } from '../../../src/commands/hook'; +import { EVENT_MAP, isEventEnabled, buildEvent, normalizeHookCwd } from '../../../src/commands/hook'; import type { IHooksConfig } from '../../../src/domain/interfaces/IHookConfig'; // ── Fixture helpers ────────────────────────────────────────────────── @@ -146,3 +146,27 @@ describe('buildEvent', () => { assert.equal('prompt' in event && event.prompt, ''); }); }); + +// ── normalizeHookCwd ──────────────────────────────────────────────── + +describe('normalizeHookCwd', () => { + it('should normalize MSYS drive path on win32', () => { + const normalized = normalizeHookCwd('/c/dev/git-mem', 'win32'); + assert.equal(normalized, 'C:/dev/git-mem'); + }); + + it('should normalize MSYS drive root on win32', () => { + const normalized = normalizeHookCwd('/d', 'win32'); + assert.equal(normalized, 'D:/'); + }); + + it('should leave non-MSYS path unchanged on win32', () => { + const normalized = normalizeHookCwd('C:/dev/git-mem', 'win32'); + assert.equal(normalized, 'C:/dev/git-mem'); + }); + + it('should leave path unchanged on non-win32', () => { + const normalized = normalizeHookCwd('/c/dev/git-mem', 'linux'); + assert.equal(normalized, '/c/dev/git-mem'); + }); +}); diff --git a/tests/unit/hooks/commit-msg.test.ts b/tests/unit/hooks/commit-msg.test.ts index b9bda7c1..1732e03e 100644 --- a/tests/unit/hooks/commit-msg.test.ts +++ b/tests/unit/hooks/commit-msg.test.ts @@ -39,8 +39,10 @@ describe('installCommitMsgHook', () => { const content = readFileSync(result.hookPath, 'utf8'); assert.ok(content.includes('#!/bin/sh')); - assert.ok(content.includes('git-mem:commit-msg v4')); + assert.ok(content.includes('git-mem:commit-msg v6')); assert.ok(content.includes('git-mem hook commit-msg')); + assert.ok(content.includes('\\"cwd\\"')); + assert.ok(content.includes('git rev-parse --show-toplevel')); }); it('should be idempotent — second install is a no-op', () => { @@ -74,7 +76,7 @@ describe('installCommitMsgHook', () => { // Installed hook should contain both fingerprint and wrapper reference const content = readFileSync(hookPath, 'utf8'); - assert.ok(content.includes('git-mem:commit-msg v4')); + assert.ok(content.includes('git-mem:commit-msg v6')); assert.ok(content.includes('user-backup')); } finally { rmSync(freshRepo, { recursive: true, force: true }); @@ -102,7 +104,7 @@ describe('installCommitMsgHook', () => { assert.equal(result.wrapped, false); const content = readFileSync(hookPath, 'utf8'); - assert.ok(content.includes('git-mem:commit-msg v4'), 'Should be upgraded to v4'); + assert.ok(content.includes('git-mem:commit-msg v6'), 'Should be upgraded to v6'); assert.ok(content.includes('git-mem hook commit-msg'), 'Should include git-mem command'); // Second install should be idempotent