From 5e00ea456ca80727f8ade7cc9e3a4d19deb340f6 Mon Sep 17 00:00:00 2001 From: JATMN Date: Thu, 29 Oct 2026 20:37:37 -0700 Subject: [PATCH] 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 --- docs/quick-start-windows.md | 13 +++++ src/utils/windowsPaths.test.ts | 96 ++++++++++++++++++++++++++++++++++ src/utils/windowsPaths.ts | 8 +-- 3 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 src/utils/windowsPaths.test.ts diff --git a/docs/quick-start-windows.md b/docs/quick-start-windows.md index 545d81a6b..2486d92d5 100644 --- a/docs/quick-start-windows.md +++ b/docs/quick-start-windows.md @@ -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 diff --git a/src/utils/windowsPaths.test.ts b/src/utils/windowsPaths.test.ts new file mode 100644 index 000000000..72bd05229 --- /dev/null +++ b/src/utils/windowsPaths.test.ts @@ -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', + }) + } + }) +}) diff --git a/src/utils/windowsPaths.ts b/src/utils/windowsPaths.ts index c0774ea87..657a32f39 100644 --- a/src/utils/windowsPaths.ts +++ b/src/utils/windowsPaths.ts @@ -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 }