diff --git a/src/common/shell-utils.ts b/src/common/shell-utils.ts index 223b95b..dfe964e 100644 --- a/src/common/shell-utils.ts +++ b/src/common/shell-utils.ts @@ -5,12 +5,19 @@ import * as path from "path"; import * as pathWin32 from "path/win32"; const WINDOWS_GIT_LOCATIONS = ["C:\\Program Files\\Git\\cmd\\git.exe", "C:\\Program Files (x86)\\Git\\cmd\\git.exe"]; +const WINDOWS_BASH_LOCATIONS = ["C:\\Program Files\\Git\\bin\\bash.exe", "C:\\Program Files (x86)\\Git\\bin\\bash.exe"]; const NUL_REDIRECT_REGEX = /(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g; let cachedGitBashPath: string | null = null; export type ShellKind = "bash" | "zsh" | "unknown"; +type WindowsGitBashLookup = { + findExecutableCandidates: (executable: string) => string[]; + findGitExecPath: () => string | null; + existsSync: (candidate: string) => boolean; +}; + export function setShellIfWindows(): void { if (process.platform !== "win32") { return; @@ -23,16 +30,30 @@ export function findGitBashPath(): string { return cachedGitBashPath; } - for (const gitPath of findAllWindowsExecutableCandidates("git")) { - const bashPath = pathWin32.join(gitPath, "..", "..", "bin", "bash.exe"); - if (fs.existsSync(bashPath)) { - cachedGitBashPath = bashPath; - return bashPath; - } + const bashPath = resolveWindowsGitBashPath({ + findExecutableCandidates: findAllWindowsExecutableCandidates, + findGitExecPath, + existsSync: fs.existsSync, + }); + if (bashPath) { + cachedGitBashPath = bashPath; + return bashPath; } throw new Error( - "Deep Code on Windows requires Git Bash. Install Git Bash for Windows and ensure bash.exe is available in PATH." + "Deep Code on Windows requires Git Bash. Install Git for Windows, or ensure Git's bash.exe is available in PATH." + ); +} + +export function resolveWindowsGitBashPath(lookup: WindowsGitBashLookup): string | null { + return firstExistingWindowsPath( + [ + ...lookup.findExecutableCandidates("bash"), + ...WINDOWS_BASH_LOCATIONS, + ...gitExecPathToBashCandidates(lookup.findGitExecPath()), + ...lookup.findExecutableCandidates("git").flatMap(gitExecutableToBashCandidates), + ], + lookup.existsSync ); } @@ -145,7 +166,8 @@ export function buildShellEnv(shellPath: string): NodeJS.ProcessEnv { } function findAllWindowsExecutableCandidates(executable: string): string[] { - const extraCandidates = executable === "git" ? WINDOWS_GIT_LOCATIONS : []; + const extraCandidates = + executable === "git" ? WINDOWS_GIT_LOCATIONS : executable === "bash" ? WINDOWS_BASH_LOCATIONS : []; try { const output = execFileSync("where.exe", [executable], { @@ -153,18 +175,67 @@ function findAllWindowsExecutableCandidates(executable: string): string[] { stdio: ["ignore", "pipe", "ignore"], windowsHide: true, }); - return filterWindowsExecutableCandidates([ - ...output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean), - ...extraCandidates, - ]); + let whereResults = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (executable === "bash") { + // Skip WSL's deprecated bash.exe launcher (C:\Windows\System32\bash.exe). + // It would start commands inside the Linux distro instead of the Windows host, + // breaking all path translations and tool invocations. + whereResults = whereResults.filter((candidate) => !/system32[\\/]bash\.exe$/i.test(candidate)); + } + return filterWindowsExecutableCandidates([...whereResults, ...extraCandidates]); } catch { return filterWindowsExecutableCandidates(extraCandidates); } } +function findGitExecPath(): string | null { + try { + const output = execFileSync("git", ["--exec-path"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }).trim(); + return output || null; + } catch { + return null; + } +} + +function gitExecPathToBashCandidates(execPath: string | null): string[] { + if (!execPath) { + return []; + } + + const normalized = execPath.replace(/\//g, "\\"); + return [ + pathWin32.join(normalized, "..", "..", "..", "bin", "bash.exe"), + pathWin32.join(normalized, "..", "..", "bin", "bash.exe"), + ]; +} + +function gitExecutableToBashCandidates(gitPath: string): string[] { + return [pathWin32.join(gitPath, "..", "..", "bin", "bash.exe"), pathWin32.join(gitPath, "..", "bin", "bash.exe")]; +} + +function firstExistingWindowsPath(candidates: string[], existsSync: (candidate: string) => boolean): string | null { + const seen = new Set(); + for (const candidate of candidates) { + const normalized = pathWin32.resolve(candidate); + const key = normalized.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + if (getShellKind(normalized) === "bash" && existsSync(normalized)) { + return normalized; + } + } + return null; +} + function filterWindowsExecutableCandidates(candidates: string[]): string[] { const cwd = process.cwd().toLowerCase(); const seen = new Set(); diff --git a/src/tests/shell-utils.test.ts b/src/tests/shell-utils.test.ts index 648db70..50a71f4 100644 --- a/src/tests/shell-utils.test.ts +++ b/src/tests/shell-utils.test.ts @@ -4,6 +4,7 @@ import { buildDisableExtglobCommand, getShellKind, posixPathToWindowsPath, + resolveWindowsGitBashPath, rewriteWindowsNullRedirect, windowsPathToPosixPath, } from "../common/shell-utils"; @@ -38,6 +39,56 @@ test("Shell kind detection supports Windows bash.exe paths", () => { assert.equal(buildDisableExtglobCommand("/bin/zsh"), "setopt NO_EXTENDED_GLOB 2>/dev/null || true"); }); +test("Windows Git Bash detection prefers bash.exe from PATH", () => { + const bashPath = "D:\\Tools\\Git\\bin\\bash.exe"; + const resolved = resolveWindowsGitBashPath({ + findExecutableCandidates: (executable) => (executable === "bash" ? [bashPath] : []), + findGitExecPath: () => null, + existsSync: (candidate) => candidate === bashPath, + }); + + assert.equal(resolved, bashPath); +}); + +test("Windows Git Bash detection derives bash.exe from git exec path", () => { + const bashPath = "D:\\Tools\\Git\\bin\\bash.exe"; + const resolved = resolveWindowsGitBashPath({ + findExecutableCandidates: () => [], + findGitExecPath: () => "D:/Tools/Git/mingw64/libexec/git-core", + existsSync: (candidate) => candidate === bashPath, + }); + + assert.equal(resolved, bashPath); +}); + +test("Windows Git Bash detection derives bash.exe from git.exe candidates", () => { + const bashPath = "D:\\Tools\\Git\\bin\\bash.exe"; + const resolved = resolveWindowsGitBashPath({ + findExecutableCandidates: (executable) => (executable === "git" ? ["D:\\Tools\\Git\\cmd\\git.exe"] : []), + findGitExecPath: () => null, + existsSync: (candidate) => candidate === bashPath, + }); + + assert.equal(resolved, bashPath); +}); + +test("Windows Git Bash detection skips WSL System32 bash.exe in PATH results", () => { + // When WSL1 is enabled on older Windows 10, C:\Windows\System32\bash.exe + // appears in PATH. That launcher would execute commands inside the Linux + // distro instead of the Windows host, breaking all tool invocations. + // The PATH bash strategy should ignore it and fall through. + const system32Bash = "C:\\Windows\\System32\\bash.exe"; + const gitBash = "D:\\Tools\\Git\\bin\\bash.exe"; + const resolved = resolveWindowsGitBashPath({ + findExecutableCandidates: (executable) => + executable === "bash" ? [system32Bash] : executable === "git" ? ["D:\\Tools\\Git\\cmd\\git.exe"] : [], + findGitExecPath: () => null, + existsSync: (candidate) => candidate === gitBash, + }); + + assert.equal(resolved, gitBash); +}); + test("File tool path normalization converts Git Bash drive paths on Windows", () => { assert.equal( normalizeFilePath("/d/IdeaProjects/guesswho-api/API_DOCUMENTATION.md", "win32"),