diff --git a/sdk/src/__tests__/run-terminal-command.test.ts b/sdk/src/__tests__/run-terminal-command.test.ts new file mode 100644 index 0000000000..56b8fe77e5 --- /dev/null +++ b/sdk/src/__tests__/run-terminal-command.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test' + +import { findWindowsBash } from '../tools/run-terminal-command' + +function existsOnly(paths: string[]) { + const existingPaths = new Set(paths.map((path) => path.toLowerCase())) + return (path: string) => existingPaths.has(path.toLowerCase()) +} + +describe('findWindowsBash', () => { + test('finds Git Bash installed by Scoop under USERPROFILE', () => { + const scoopBash = String.raw`C:\Users\dev\scoop\apps\git\current\bin\bash.exe` + + expect( + findWindowsBash( + { USERPROFILE: String.raw`C:\Users\dev`, PATH: '' }, + existsOnly([scoopBash]), + ), + ).toBe(scoopBash) + }) + + test('finds Git Bash installed by Scoop under SCOOP_GLOBAL', () => { + const scoopBash = String.raw`C:\ProgramData\scoop\apps\git\current\usr\bin\bash.exe` + + expect( + findWindowsBash( + { + SCOOP_GLOBAL: String.raw`C:\ProgramData\scoop`, + USERPROFILE: String.raw`C:\Users\dev`, + PATH: '', + }, + existsOnly([scoopBash]), + ), + ).toBe(scoopBash) + }) + + test('prefers non-WSL bash in PATH over WSL bash', () => { + const gitBash = String.raw`C:\Tools\Git\bin\bash.exe` + const wslBash = String.raw`C:\Windows\System32\bash.exe` + + expect( + findWindowsBash( + { + PATH: String.raw`C:\Windows\System32;C:\Tools\Git\bin`, + }, + existsOnly([gitBash, wslBash]), + ), + ).toBe(gitBash) + }) +}) diff --git a/sdk/src/tools/run-terminal-command.ts b/sdk/src/tools/run-terminal-command.ts index ef04a969f5..d48941f344 100644 --- a/sdk/src/tools/run-terminal-command.ts +++ b/sdk/src/tools/run-terminal-command.ts @@ -22,10 +22,23 @@ const GIT_BASH_COMMON_PATHS = [ // WSL bash paths that are often unreliable (VM may not be running, quote escaping issues) // These are checked last as a fallback only -const WSL_BASH_PATH_PATTERNS = [ - 'system32', - 'windowsapps', -] +const WSL_BASH_PATH_PATTERNS = ['system32', 'windowsapps'] + +function getScoopGitBashPaths(env: NodeJS.ProcessEnv): string[] { + const scoopBases = [ + env.SCOOP, + env.USERPROFILE ? path.win32.join(env.USERPROFILE, 'scoop') : undefined, + env.SCOOP_GLOBAL, + ].filter((base): base is string => Boolean(base)) + + return [...new Set(scoopBases)].flatMap((scoopBase) => { + const gitDir = path.win32.join(scoopBase, 'apps', 'git', 'current') + return [ + path.win32.join(gitDir, 'bin', 'bash.exe'), + path.win32.join(gitDir, 'usr', 'bin', 'bash.exe'), + ] + }) +} /** * Find bash executable on Windows. @@ -34,37 +47,48 @@ const WSL_BASH_PATH_PATTERNS = [ * 2. Common Git Bash installation locations (most reliable) * 3. Non-WSL bash in PATH (e.g., Git Bash added to PATH) * 4. WSL bash in PATH (last resort - System32, WindowsApps) - * + * * WSL bash is deprioritized because it can fail with cryptic errors when: * - The WSL VM is not running * - Quote/argument escaping issues between Windows and Linux * - UTF-16 encoding mismatches */ -function findWindowsBash(env: NodeJS.ProcessEnv): string | null { +export function findWindowsBash( + env: NodeJS.ProcessEnv, + existsSync: (path: string) => boolean = fs.existsSync, +): string | null { // Check for user-specified path via environment variable const customPath = env.CODEBUFF_GIT_BASH_PATH - if (customPath && fs.existsSync(customPath)) { + if (customPath && existsSync(customPath)) { return customPath } // Check common Git Bash installation locations first (most reliable) for (const commonPath of GIT_BASH_COMMON_PATHS) { - if (fs.existsSync(commonPath)) { + if (existsSync(commonPath)) { return commonPath } } + for (const scoopPath of getScoopGitBashPaths(env)) { + if (existsSync(scoopPath)) { + return scoopPath + } + } + // Fall back to bash.exe in PATH, but skip WSL paths initially const pathEnv = env.PATH || env.Path || '' - const pathDirs = pathEnv.split(path.delimiter) + const pathDirs = pathEnv.split(';') const wslFallbackPaths: string[] = [] - + for (const dir of pathDirs) { const dirLower = dir.toLowerCase() - const isWslPath = WSL_BASH_PATH_PATTERNS.some(pattern => dirLower.includes(pattern)) - - const bashPath = path.join(dir, 'bash.exe') - if (fs.existsSync(bashPath)) { + const isWslPath = WSL_BASH_PATH_PATTERNS.some((pattern) => + dirLower.includes(pattern), + ) + + const bashPath = path.win32.join(dir, 'bash.exe') + if (existsSync(bashPath)) { if (isWslPath) { // Save WSL paths for last resort wslFallbackPaths.push(bashPath) @@ -73,10 +97,10 @@ function findWindowsBash(env: NodeJS.ProcessEnv): string | null { return bashPath } } - + // Also check for just 'bash' (without .exe) - const bashPathNoExt = path.join(dir, 'bash') - if (fs.existsSync(bashPathNoExt)) { + const bashPathNoExt = path.win32.join(dir, 'bash') + if (existsSync(bashPathNoExt)) { if (isWslPath) { wslFallbackPaths.push(bashPathNoExt) } else {