From 28059157fff92d8bc43d20bb1253706396d1a240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 19 Apr 2026 14:37:35 +0800 Subject: [PATCH] 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 --- src/utils/hooks.ts | 46 +++++++++++++++++++++++++++------------ src/utils/windowsPaths.ts | 44 +++++++++++++++++++++++++++++-------- 2 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index 41985744..5b3e519b 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -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, diff --git a/src/utils/windowsPaths.ts b/src/utils/windowsPaths.ts index c0774ea8..776c2e34 100644 --- a/src/utils/windowsPaths.ts +++ b/src/utils/windowsPaths.ts @@ -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',