Stop treating Git Bash as a hard startup dependency on Windows desktop sessions

Windows desktop sessions were failing during early hook execution because the
runtime assumed Git Bash was always installed. That made a missing Git
installation look like a fatal CLI startup regression even though PowerShell is
usually available and sufficient for default hook execution.

This change turns Git Bash lookup into a best-effort probe, leaves SHELL unset
when Git Bash is missing, and falls back to PowerShell for default Windows hook
execution. Explicit bash hooks still fail with a direct installation hint, but
they no longer hard-exit the whole process.

Constraint: Windows users may have PowerShell but no Git for Windows installed
Rejected: Keep hard-exiting on missing Git Bash | makes first-run desktop chat fail for a recoverable missing dependency
Rejected: Bundle Git for Windows into the app | larger distribution and higher maintenance burden than a runtime fallback
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Treat Git Bash as an optional Windows capability unless a code path explicitly requires bash semantics
Tested: bun test src/server/__tests__/conversation-service.test.ts
Tested: bun -e "await import('./src/utils/windowsPaths.ts'); await import('./src/utils/hooks.ts'); console.log('module-import-ok')"
Not-tested: End-to-end desktop chat startup on a clean Windows Server host without Git installed
Not-tested: Explicit bash hook execution on Windows after the fallback path
Related: GitHub issue #62
This commit is contained in:
程序员阿江(Relakkes) 2026-04-19 14:37:35 +08:00
parent b7396e4f65
commit 28059157ff
2 changed files with 67 additions and 23 deletions

View File

@ -17,7 +17,10 @@ import {
} from './sessionEnvironment.js'
import { subprocessEnv } from './subprocessEnv.js'
import { getPlatform } from './platform.js'
import { findGitBashPath, windowsPathToPosixPath } from './windowsPaths.js'
import {
tryFindGitBashPath,
windowsPathToPosixPath,
} from './windowsPaths.js'
import { getCachedPowerShellPath } from './shell/powershellDetection.js'
import { DEFAULT_HOOK_SHELL } from './shell/shellProvider.js'
import { buildPowerShellArgs } from './shell/powershellProvider.js'
@ -787,9 +790,20 @@ async function execCommandHook(
// PowerShell path deliberately skips the Windows-specific bash
// accommodations (cygpath conversion, .sh auto-prepend, POSIX-quoted
// SHELL_PREFIX).
const shellType = hook.shell ?? DEFAULT_HOOK_SHELL
let shellType = hook.shell ?? DEFAULT_HOOK_SHELL
const isPowerShell = shellType === 'powershell'
if (isWindows && !hook.shell && shellType === 'bash' && !tryFindGitBashPath()) {
const pwshPath = await getCachedPowerShellPath()
if (pwshPath) {
shellType = 'powershell'
logForDebugging(
`Hooks: Git Bash unavailable on Windows, falling back to PowerShell for default shell on hook "${hook.command}"`,
{ level: 'warn' },
)
}
}
const usesPowerShell = shellType === 'powershell'
// --
// Windows bash path: hooks run via Git Bash (Cygwin), NOT cmd.exe.
@ -806,7 +820,7 @@ async function execCommandHook(
// PowerShell expects Windows paths on Windows (and native paths on
// Unix where pwsh is also available).
const toHookPath =
isWindows && !isPowerShell
isWindows && !usesPowerShell
? (p: string) => windowsPathToPosixPath(p)
: (p: string) => p
@ -859,7 +873,7 @@ async function execCommandHook(
// On Windows (bash only), auto-prepend `bash` for .sh scripts so they
// execute instead of opening in the default file handler. PowerShell
// runs .ps1 files natively — no prepend needed.
if (isWindows && !isPowerShell && command.trim().match(/\.sh(\s|$|")/)) {
if (isWindows && !usesPowerShell && command.trim().match(/\.sh(\s|$|")/)) {
if (!command.trim().startsWith('bash ')) {
command = `bash ${command}`
}
@ -870,7 +884,7 @@ async function execCommandHook(
// PowerShell — see design §8.1. For now PS hooks ignore the prefix;
// a CLAUDE_CODE_PS_SHELL_PREFIX (or shell-aware prefix) is a follow-up.
const finalCommand =
!isPowerShell && process.env.CLAUDE_CODE_SHELL_PREFIX
!usesPowerShell && process.env.CLAUDE_CODE_SHELL_PREFIX
? formatShellPrefixCommand(process.env.CLAUDE_CODE_SHELL_PREFIX, command)
: command
@ -915,7 +929,7 @@ async function execCommandHook(
// Skip for PS — consistent with how .sh prepend and SHELL_PREFIX are
// already bash-only above.
if (
!isPowerShell &&
!usesPowerShell &&
(hookEvent === 'SessionStart' ||
hookEvent === 'Setup' ||
hookEvent === 'CwdChanged' ||
@ -948,12 +962,9 @@ async function execCommandHook(
// skips user profile scripts (faster, deterministic).
// -NonInteractive fails fast instead of prompting.
//
// The Git Bash hard-exit in findGitBashPath() is still in place for
// bash hooks. PowerShell hooks never call it, so a Windows user with
// only pwsh and shell: 'powershell' on every hook could in theory run
// without Git Bash — but init.ts still calls setShellIfWindows() on
// startup, which will exit first. Relaxing that is phase 1 of the
// design's implementation order (separate PR).
// On Windows, default-shell hooks now degrade to PowerShell when Git Bash
// is unavailable. Explicit bash hooks still surface an actionable error,
// but they no longer hard-exit the entire process.
let child: ChildProcessWithoutNullStreams
if (shellType === 'powershell') {
const pwshPath = await getCachedPowerShellPath()
@ -973,7 +984,14 @@ async function execCommandHook(
} else {
// On Windows, use Git Bash explicitly (cmd.exe can't run bash syntax).
// On other platforms, shell: true uses /bin/sh.
const shell = isWindows ? findGitBashPath() : true
const shell = isWindows
? tryFindGitBashPath() ??
(() => {
throw new Error(
'Git Bash is required to run bash hooks on Windows. Install Git for Windows or configure the hook with shell: "powershell".',
)
})()
: true
child = spawn(finalCommand, [], {
env: envVars,
cwd: safeCwd,

View File

@ -86,26 +86,31 @@ function findExecutable(executable: string): string | null {
*/
export function setShellIfWindows(): void {
if (getPlatform() === 'windows') {
const gitBashPath = findGitBashPath()
const gitBashPath = tryFindGitBashPath()
if (!gitBashPath) {
logForDebugging(
'Git Bash not found on Windows; leaving SHELL unset for lazy fallback handling',
{ level: 'warn' },
)
return
}
process.env.SHELL = gitBashPath
logForDebugging(`Using bash path: "${gitBashPath}"`)
}
}
/**
* Find the path where `bash.exe` included with git-bash exists, exiting the process if not found.
* Best-effort Git Bash resolution on Windows.
*
* Returns null when Git Bash is unavailable so callers can decide whether to
* fall back (for example, to PowerShell) instead of hard-exiting the process.
*/
export const findGitBashPath = memoize((): string => {
export const tryFindGitBashPath = memoize((): string | null => {
if (process.env.CLAUDE_CODE_GIT_BASH_PATH) {
if (checkPathExists(process.env.CLAUDE_CODE_GIT_BASH_PATH)) {
return process.env.CLAUDE_CODE_GIT_BASH_PATH
}
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(
`Claude Code was unable to find CLAUDE_CODE_GIT_BASH_PATH path "${process.env.CLAUDE_CODE_GIT_BASH_PATH}"`,
)
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(1)
return null
}
const gitPath = findExecutable('git')
@ -116,6 +121,27 @@ export const findGitBashPath = memoize((): string => {
}
}
return null
})
/**
* Find the path where `bash.exe` included with git-bash exists, exiting the process if not found.
*/
export const findGitBashPath = memoize((): string => {
const gitBashPath = tryFindGitBashPath()
if (gitBashPath) {
return gitBashPath
}
if (process.env.CLAUDE_CODE_GIT_BASH_PATH) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(
`Claude Code was unable to find CLAUDE_CODE_GIT_BASH_PATH path "${process.env.CLAUDE_CODE_GIT_BASH_PATH}"`,
)
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(1)
}
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(
'Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win). If installed but not in PATH, set environment variable pointing to your bash.exe, similar to: CLAUDE_CODE_GIT_BASH_PATH=C:\\Program Files\\Git\\bin\\bash.exe',