fix(windows): validate Git Bash paths without a shell probe (#2470)

* fix(windows): validate Git Bash paths without a shell probe

* docs(windows): clarify Git Bash override precedence
This commit is contained in:
JATMN
2026-10-29 20:37:37 -07:00
committed by GitHub
parent 42b108d19d
commit 5e00ea456c
3 changed files with 113 additions and 4 deletions
+13
View File
@@ -35,6 +35,19 @@ If you set up the optional PowerShell aliases, your `$PROFILE` still loads them
from the old package folder and will show an error in every new window. See
[Upgrading from `@gitlawb/openclaude`](windows-aliases-and-launchers.md#upgrading-from-gitlawbopenclaude).
### Git Bash for shell commands
Install [Git for Windows](https://git-scm.com/downloads/win) for Bash commands
and Bash hooks. OpenClaude checks the default Git installation locations, then
looks for Git on `PATH`. For a custom installation, set
`CLAUDE_CODE_GIT_BASH_PATH` in the same PowerShell session before launching
OpenClaude. Use the path to `bin\bash.exe`, not the `git-bash.exe` terminal
launcher. An explicit override takes precedence over automatic discovery.
If OpenClaude reports that the configured path cannot be found, check that it
points to an existing file. Path validation uses the filesystem directly and
does not require a working `dir` command or command interpreter.
## 3. Pick One Provider
### Option A: OpenAI
+96
View File
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import * as cwd from './cwd.js'
import * as execSyncWrapper from './execSyncWrapper.js'
import { getFsImplementation } from './fsOperations.js'
import * as platform from './platform.js'
import { findGitBashPath, setShellIfWindows } from './windowsPaths.js'
describe('Windows Git Bash discovery', () => {
let tempDir: string
const originalOverride = process.env.CLAUDE_CODE_GIT_BASH_PATH
const originalShell = process.env.SHELL
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'git-bash-path-'))
delete process.env.CLAUDE_CODE_GIT_BASH_PATH
findGitBashPath.cache.clear?.()
spyOn(platform, 'getPlatform').mockReturnValue('windows')
spyOn(console, 'error').mockImplementation(() => {})
spyOn(process, 'exit').mockImplementation(() => {
throw new Error('exit requested')
})
})
afterEach(() => {
mock.restore()
findGitBashPath.cache.clear?.()
if (originalOverride === undefined) delete process.env.CLAUDE_CODE_GIT_BASH_PATH
else process.env.CLAUDE_CODE_GIT_BASH_PATH = originalOverride
if (originalShell === undefined) delete process.env.SHELL
else process.env.SHELL = originalShell
rmSync(tempDir, { recursive: true, force: true })
})
test('sets SHELL to an existing override even when system shell commands fail', () => {
const bashPath = join(tempDir, 'Git Bash & tools', 'bash.exe')
mkdirSync(join(tempDir, 'Git Bash & tools'))
writeFileSync(bashPath, '')
process.env.CLAUDE_CODE_GIT_BASH_PATH = bashPath
const exec = spyOn(execSyncWrapper, 'execSync_DEPRECATED').mockImplementation(() => {
throw new Error('command interpreter unavailable')
})
setShellIfWindows()
expect(process.env.SHELL).toBe(bashPath)
expect(findGitBashPath()).toBe(bashPath)
expect(exec).not.toHaveBeenCalled()
})
test.each(['missing', 'directory'])('rejects a %s override without falling back', kind => {
const bashPath = join(tempDir, 'bash.exe')
if (kind === 'directory') mkdirSync(bashPath)
process.env.CLAUDE_CODE_GIT_BASH_PATH = bashPath
const exec = spyOn(execSyncWrapper, 'execSync_DEPRECATED').mockReturnValue('')
expect(() => setShellIfWindows()).toThrow('exit requested')
expect(process.exit).toHaveBeenCalledWith(1)
expect(console.error).toHaveBeenCalledWith(
`Claude Code was unable to find CLAUDE_CODE_GIT_BASH_PATH path "${bashPath}"`,
)
expect(exec).not.toHaveBeenCalled()
})
test.each([
['default', 'C:\\Program Files\\Git'],
['custom', 'D:\\Developer Tools\\Git'],
])('discovers a %s installation without using dir', (kind, gitRoot) => {
spyOn(cwd, 'getCwd').mockReturnValue('C:\\workspace')
// Model Windows paths on any host, backed by a real file's stat result.
const file = join(tempDir, 'executable')
writeFileSync(file, '')
const fs = getFsImplementation()
const executableStat = fs.statSync(file)
const gitPath = `${gitRoot}\\cmd\\git.exe`
const bashPath = `${gitRoot}\\bin\\bash.exe`
spyOn(fs, 'statSync').mockImplementation(candidate => {
if (candidate === gitPath || candidate === bashPath) return executableStat
throw new Error('file not found')
})
const exec = spyOn(execSyncWrapper, 'execSync_DEPRECATED').mockReturnValue(`${gitPath}\r\n`)
setShellIfWindows()
expect(process.env.SHELL).toBe(bashPath)
expect(exec).toHaveBeenCalledTimes(kind === 'default' ? 0 : 1)
if (kind === 'custom') {
expect(exec).toHaveBeenCalledWith('where.exe git', {
stdio: 'pipe',
encoding: 'utf8',
})
}
})
})
+4 -4
View File
@@ -4,18 +4,18 @@ import * as pathWin32 from 'path/win32'
import { getCwd } from './cwd.js'
import { logForDebugging } from './debug.js'
import { execSync_DEPRECATED } from './execSyncWrapper.js'
import { getFsImplementation } from './fsOperations.js'
import { memoizeWithLRU } from './memoize.js'
import { getPlatform } from './platform.js'
/**
* Check if a file or directory exists on Windows using the dir command
* Check executable candidates directly, without depending on the system shell.
* @param path - The path to check
* @returns true if the path exists, false otherwise
* @returns true if the path is a file, false otherwise
*/
function checkPathExists(path: string): boolean {
try {
execSync_DEPRECATED(`dir "${path}"`, { stdio: 'pipe' })
return true
return getFsImplementation().statSync(path).isFile()
} catch {
return false
}