Local-install managed .sh hooks under Claude Code on Windows were wrapped with the absolute Git Bash path; Claude runs the hook string inside Git Bash, so bash tried to exec bash (cannot execute binary file). Centralizes the win32+claude+.sh guard (shellHookOmitsBashRunner) and adds an exported, testable buildLocalShellHookCommand so the local path matches the global path. Closes the #166/#377 regression in the local-install branch. Adds a Windows-covered regression test. Fixes #580
This commit is contained in:
5
.changeset/calm-cranes-roar.md
Normal file
5
.changeset/calm-cranes-roar.md
Normal file
@@ -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)
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
116
tests/bug-580-local-sh-hook-bash-wrapper.test.cjs
Normal file
116
tests/bug-580-local-sh-hook-bash-wrapper.test.cjs
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user