mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 12:50:54 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user