diff --git a/.changeset/calm-cranes-roar.md b/.changeset/calm-cranes-roar.md new file mode 100644 index 000000000..648f41088 --- /dev/null +++ b/.changeset/calm-cranes-roar.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 583 +--- +**Local-install `.sh` hooks no longer fail on Claude Code/Windows** — on a local install, managed `.sh` hooks (`gsd-session-state.sh`, `gsd-validate-commit.sh`, `gsd-graphify-update.sh`, `gsd-phase-boundary.sh`) were emitted wrapped with the absolute Git Bash path. Because Claude Code runs the hook command string inside Git Bash, the explicit `bash.exe` became the binary bash tried to exec → `cannot execute binary file` on every hook event. The local path now drops the `bash.exe` wrapper and emits the `$CLAUDE_PROJECT_DIR`-anchored script path, matching the global install path's #166/#377 guard. (#580) diff --git a/bin/install.js b/bin/install.js index 5412a8ffc..e124221e8 100755 --- a/bin/install.js +++ b/bin/install.js @@ -16,6 +16,8 @@ const { projectPersistentPathExportActions, projectShellCommandText, projectCodexHookTomlCommand, + shellHookOmitsBashRunner, + buildLocalShellHookCommand, } = require('../get-shit-done/bin/lib/shell-command-projection.cjs'); // Bidirectional GSD slash-command namespace transformer (#3583). @@ -1217,7 +1219,7 @@ function buildHookCommand(configDir, hookName, opts) { // Windows, so wrapping `.sh` hooks with an explicit `bash.exe` path can // trigger `bash.exe: ... cannot execute binary file`. Emit only the quoted // script path for Claude on Windows. - if (platform === 'win32' && runtime === 'claude' && isShellHook) { + if (shellHookOmitsBashRunner({ platform, runtime, isShellHook })) { if (opts.portableHooks) { const portableBaseDir = projectPortableHookBaseDir({ configDir, @@ -9699,14 +9701,13 @@ function install(isGlobal, runtime = 'claude', options = {}) { runtime, platform: process.platform, }); - const localShellCmd = (hookFile) => localBashRunner === null - ? null - : projectShellCommandText({ - runnerToken: localBashRunner, - argTokens: [`${localPrefix}/hooks/${hookFile}`], - runtime, - platform: process.platform, - }); + const localShellCmd = (hookFile) => buildLocalShellHookCommand({ + localPrefix, + hookFile, + bashRunner: localBashRunner, + runtime, + platform: process.platform, + }); const statuslineCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-statusline.js', hookOpts) : localCmd('gsd-statusline.js'); diff --git a/get-shit-done/bin/lib/shell-command-projection.cjs b/get-shit-done/bin/lib/shell-command-projection.cjs index 576263d58..8a06b040f 100644 --- a/get-shit-done/bin/lib/shell-command-projection.cjs +++ b/get-shit-done/bin/lib/shell-command-projection.cjs @@ -41,6 +41,39 @@ function formatHookCommandForRuntime(command, opts = {}) { return hookCommandNeedsPowerShellCallOperator(opts) ? `& ${command}` : command; } +// #166/#580: Claude Code on Windows executes hook command strings inside Git +// Bash. A `.sh` hook wrapped with an explicit bash.exe path makes bash try to +// exec bash itself ("C:/.../bash.exe: cannot execute binary file"). Both install +// paths — global (buildHookCommand) and local (buildLocalShellHookCommand) — must +// drop the bash runner in this case and emit only the anchored script path. +// Centralized here so the two paths cannot silently drift apart again: the local +// path missed this guard and reintroduced the #166/#377 failure (#580). +function shellHookOmitsBashRunner({ platform = process.platform, runtime = 'generic', isShellHook = false } = {}) { + return platform === 'win32' && runtime === 'claude' && isShellHook; +} + +// Builds the command string for a local-install managed `.sh` hook. Mirrors the +// global buildHookCommand path but uses the $CLAUDE_PROJECT_DIR-anchored prefix +// instead of an absolute configDir. On Claude/Windows the bash runner is dropped +// (see shellHookOmitsBashRunner) and the anchored script path is emitted alone — +// matching the global path. Elsewhere the resolved bash runner is required; a +// null runner yields null so callers skip registration instead of emitting a +// broken hook (#3393). +function buildLocalShellHookCommand({ localPrefix, hookFile, bashRunner, runtime = 'generic', platform = process.platform }) { + if (!localPrefix || !hookFile) return null; + const scriptPath = `${localPrefix}/hooks/${hookFile}`; + if (shellHookOmitsBashRunner({ platform, runtime, isShellHook: true })) { + return formatHookCommandForRuntime(scriptPath, { platform, runtime }); + } + if (!bashRunner) return null; + return projectShellCommandText({ + runnerToken: bashRunner, + argTokens: [scriptPath], + runtime, + platform, + }); +} + /** * Project a managed hook script path token for serialized shell commands. * Windows managed hook commands normalize to forward slashes so the same path @@ -476,6 +509,8 @@ function platformEnsureDir(dirPath) { module.exports = { hookCommandNeedsPowerShellCallOperator, formatHookCommandForRuntime, + shellHookOmitsBashRunner, + buildLocalShellHookCommand, formatManagedHookScriptToken, projectLocalHookPrefix, projectPortableHookBaseDir, diff --git a/tests/bug-580-local-sh-hook-bash-wrapper.test.cjs b/tests/bug-580-local-sh-hook-bash-wrapper.test.cjs new file mode 100644 index 000000000..e12601c78 --- /dev/null +++ b/tests/bug-580-local-sh-hook-bash-wrapper.test.cjs @@ -0,0 +1,116 @@ +'use strict'; + +/** + * Regression test for bug #580. + * + * LOCAL install under Claude Code on Windows: managed `.sh` hooks were emitted + * wrapped with an absolute `bash.exe` path via the `localShellCmd` arrow. + * Since Claude Code runs hook command strings INSIDE Git Bash, bash tries to + * exec bash → "cannot execute binary file". + * + * The GLOBAL path (`buildHookCommand`) already guarded win32+claude+.sh (#166). + * The LOCAL path (`localShellCmd`) did not. Fix: add `buildLocalShellHookCommand` + * and `shellHookOmitsBashRunner` to shell-command-projection.cjs, and use + * `buildLocalShellHookCommand` from install.js local-install path. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const projection = require(path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'shell-command-projection.cjs')); +const { buildLocalShellHookCommand, shellHookOmitsBashRunner, projectLocalHookPrefix } = projection; + +describe('bug #580: local .sh hooks on Claude/Windows must NOT wrap with bash.exe', () => { + test('local .sh hook on Claude/Windows omits the bash.exe wrapper (#580)', () => { + const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); + const result = buildLocalShellHookCommand({ + localPrefix, + hookFile: 'gsd-session-state.sh', + bashRunner: '"C:/Program Files/Git/bin/bash.exe"', + runtime: 'claude', + platform: 'win32', + }); + assert.equal(result, '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh'); + assert.ok(!result.includes('bash.exe'), `result must not contain bash.exe, got: ${result}`); + }); + + test('local .sh hook on Claude/Windows still emits script path when bash.exe is unresolved', () => { + const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); + const result = buildLocalShellHookCommand({ + localPrefix, + hookFile: 'gsd-session-state.sh', + bashRunner: null, + runtime: 'claude', + platform: 'win32', + }); + assert.equal(result, '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh'); + }); + + test('local .sh hook on POSIX keeps the bash runner', () => { + const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); + const result = buildLocalShellHookCommand({ + localPrefix, + hookFile: 'gsd-session-state.sh', + bashRunner: 'bash', + runtime: 'claude', + platform: 'linux', + }); + assert.equal(result, 'bash "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-session-state.sh'); + }); + + test('local .sh hook on Windows non-Claude runtime keeps the bash runner', () => { + const localPrefix = projectLocalHookPrefix({ runtime: 'codex', dirName: '.claude' }); + const result = buildLocalShellHookCommand({ + localPrefix, + hookFile: 'gsd-session-state.sh', + bashRunner: '"C:/Program Files/Git/bin/bash.exe"', + runtime: 'codex', + platform: 'win32', + }); + assert.ok(result.includes('bash.exe'), `result must contain bash.exe, got: ${result}`); + assert.ok(result.startsWith('"C:/Program Files/Git/bin/bash.exe"'), `result must start with bash.exe token, got: ${result}`); + }); + + test('all four managed local .sh hooks drop the wrapper on Claude/Windows', () => { + const localPrefix = projectLocalHookPrefix({ runtime: 'claude', dirName: '.claude' }); + const hooks = [ + 'gsd-session-state.sh', + 'gsd-validate-commit.sh', + 'gsd-graphify-update.sh', + 'gsd-phase-boundary.sh', + ]; + for (const f of hooks) { + const result = buildLocalShellHookCommand({ + localPrefix, + hookFile: f, + bashRunner: '"C:/Program Files/Git/bin/bash.exe"', + runtime: 'claude', + platform: 'win32', + }); + assert.equal( + result, + `"$CLAUDE_PROJECT_DIR"/.claude/hooks/${f}`, + `expected script-only path for ${f}, got: ${result}`, + ); + assert.ok(!result.includes('bash.exe'), `result for ${f} must not contain bash.exe, got: ${result}`); + } + }); + + test('shellHookOmitsBashRunner truth table', () => { + // true only for win32 + claude + isShellHook:true + assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'claude', isShellHook: true }), true); + + // false for win32 + claude + isShellHook:false + assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'claude', isShellHook: false }), false); + + // false for win32 + codex + isShellHook:true + assert.equal(shellHookOmitsBashRunner({ platform: 'win32', runtime: 'codex', isShellHook: true }), false); + + // false for linux + claude + isShellHook:true + assert.equal(shellHookOmitsBashRunner({ platform: 'linux', runtime: 'claude', isShellHook: true }), false); + + // false for default args (no win32, no claude) + assert.equal(shellHookOmitsBashRunner(), false); + }); +});