Skip to content

Commit 0c24248

Browse files
authored
Merge pull request #55 from yuefengw/fix/windows-git-bash-detection
fix: improve Windows Git Bash detection
2 parents 33e7dbd + 327605c commit 0c24248

2 files changed

Lines changed: 137 additions & 15 deletions

File tree

src/common/shell-utils.ts

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,19 @@ import * as path from "path";
55
import * as pathWin32 from "path/win32";
66

77
const WINDOWS_GIT_LOCATIONS = ["C:\\Program Files\\Git\\cmd\\git.exe", "C:\\Program Files (x86)\\Git\\cmd\\git.exe"];
8+
const WINDOWS_BASH_LOCATIONS = ["C:\\Program Files\\Git\\bin\\bash.exe", "C:\\Program Files (x86)\\Git\\bin\\bash.exe"];
89

910
const NUL_REDIRECT_REGEX = /(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g;
1011
let cachedGitBashPath: string | null = null;
1112

1213
export type ShellKind = "bash" | "zsh" | "unknown";
1314

15+
type WindowsGitBashLookup = {
16+
findExecutableCandidates: (executable: string) => string[];
17+
findGitExecPath: () => string | null;
18+
existsSync: (candidate: string) => boolean;
19+
};
20+
1421
export function setShellIfWindows(): void {
1522
if (process.platform !== "win32") {
1623
return;
@@ -23,16 +30,30 @@ export function findGitBashPath(): string {
2330
return cachedGitBashPath;
2431
}
2532

26-
for (const gitPath of findAllWindowsExecutableCandidates("git")) {
27-
const bashPath = pathWin32.join(gitPath, "..", "..", "bin", "bash.exe");
28-
if (fs.existsSync(bashPath)) {
29-
cachedGitBashPath = bashPath;
30-
return bashPath;
31-
}
33+
const bashPath = resolveWindowsGitBashPath({
34+
findExecutableCandidates: findAllWindowsExecutableCandidates,
35+
findGitExecPath,
36+
existsSync: fs.existsSync,
37+
});
38+
if (bashPath) {
39+
cachedGitBashPath = bashPath;
40+
return bashPath;
3241
}
3342

3443
throw new Error(
35-
"Deep Code on Windows requires Git Bash. Install Git Bash for Windows and ensure bash.exe is available in PATH."
44+
"Deep Code on Windows requires Git Bash. Install Git for Windows, or ensure Git's bash.exe is available in PATH."
45+
);
46+
}
47+
48+
export function resolveWindowsGitBashPath(lookup: WindowsGitBashLookup): string | null {
49+
return firstExistingWindowsPath(
50+
[
51+
...lookup.findExecutableCandidates("bash"),
52+
...WINDOWS_BASH_LOCATIONS,
53+
...gitExecPathToBashCandidates(lookup.findGitExecPath()),
54+
...lookup.findExecutableCandidates("git").flatMap(gitExecutableToBashCandidates),
55+
],
56+
lookup.existsSync
3657
);
3758
}
3859

@@ -146,26 +167,76 @@ export function buildShellEnv(shellPath: string, extraEnv: Record<string, string
146167
}
147168

148169
function findAllWindowsExecutableCandidates(executable: string): string[] {
149-
const extraCandidates = executable === "git" ? WINDOWS_GIT_LOCATIONS : [];
170+
const extraCandidates =
171+
executable === "git" ? WINDOWS_GIT_LOCATIONS : executable === "bash" ? WINDOWS_BASH_LOCATIONS : [];
150172

151173
try {
152174
const output = execFileSync("where.exe", [executable], {
153175
encoding: "utf8",
154176
stdio: ["ignore", "pipe", "ignore"],
155177
windowsHide: true,
156178
});
157-
return filterWindowsExecutableCandidates([
158-
...output
159-
.split(/\r?\n/)
160-
.map((line) => line.trim())
161-
.filter(Boolean),
162-
...extraCandidates,
163-
]);
179+
let whereResults = output
180+
.split(/\r?\n/)
181+
.map((line) => line.trim())
182+
.filter(Boolean);
183+
if (executable === "bash") {
184+
// Skip WSL's deprecated bash.exe launcher (C:\Windows\System32\bash.exe).
185+
// It would start commands inside the Linux distro instead of the Windows host,
186+
// breaking all path translations and tool invocations.
187+
whereResults = whereResults.filter((candidate) => !/system32[\\/]bash\.exe$/i.test(candidate));
188+
}
189+
return filterWindowsExecutableCandidates([...whereResults, ...extraCandidates]);
164190
} catch {
165191
return filterWindowsExecutableCandidates(extraCandidates);
166192
}
167193
}
168194

195+
function findGitExecPath(): string | null {
196+
try {
197+
const output = execFileSync("git", ["--exec-path"], {
198+
encoding: "utf8",
199+
stdio: ["ignore", "pipe", "ignore"],
200+
windowsHide: true,
201+
}).trim();
202+
return output || null;
203+
} catch {
204+
return null;
205+
}
206+
}
207+
208+
function gitExecPathToBashCandidates(execPath: string | null): string[] {
209+
if (!execPath) {
210+
return [];
211+
}
212+
213+
const normalized = execPath.replace(/\//g, "\\");
214+
return [
215+
pathWin32.join(normalized, "..", "..", "..", "bin", "bash.exe"),
216+
pathWin32.join(normalized, "..", "..", "bin", "bash.exe"),
217+
];
218+
}
219+
220+
function gitExecutableToBashCandidates(gitPath: string): string[] {
221+
return [pathWin32.join(gitPath, "..", "..", "bin", "bash.exe"), pathWin32.join(gitPath, "..", "bin", "bash.exe")];
222+
}
223+
224+
function firstExistingWindowsPath(candidates: string[], existsSync: (candidate: string) => boolean): string | null {
225+
const seen = new Set<string>();
226+
for (const candidate of candidates) {
227+
const normalized = pathWin32.resolve(candidate);
228+
const key = normalized.toLowerCase();
229+
if (seen.has(key)) {
230+
continue;
231+
}
232+
seen.add(key);
233+
if (getShellKind(normalized) === "bash" && existsSync(normalized)) {
234+
return normalized;
235+
}
236+
}
237+
return null;
238+
}
239+
169240
function filterWindowsExecutableCandidates(candidates: string[]): string[] {
170241
const cwd = process.cwd().toLowerCase();
171242
const seen = new Set<string>();

src/tests/shell-utils.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
buildDisableExtglobCommand,
55
getShellKind,
66
posixPathToWindowsPath,
7+
resolveWindowsGitBashPath,
78
rewriteWindowsNullRedirect,
89
windowsPathToPosixPath,
910
} from "../common/shell-utils";
@@ -38,6 +39,56 @@ test("Shell kind detection supports Windows bash.exe paths", () => {
3839
assert.equal(buildDisableExtglobCommand("/bin/zsh"), "setopt NO_EXTENDED_GLOB 2>/dev/null || true");
3940
});
4041

42+
test("Windows Git Bash detection prefers bash.exe from PATH", () => {
43+
const bashPath = "D:\\Tools\\Git\\bin\\bash.exe";
44+
const resolved = resolveWindowsGitBashPath({
45+
findExecutableCandidates: (executable) => (executable === "bash" ? [bashPath] : []),
46+
findGitExecPath: () => null,
47+
existsSync: (candidate) => candidate === bashPath,
48+
});
49+
50+
assert.equal(resolved, bashPath);
51+
});
52+
53+
test("Windows Git Bash detection derives bash.exe from git exec path", () => {
54+
const bashPath = "D:\\Tools\\Git\\bin\\bash.exe";
55+
const resolved = resolveWindowsGitBashPath({
56+
findExecutableCandidates: () => [],
57+
findGitExecPath: () => "D:/Tools/Git/mingw64/libexec/git-core",
58+
existsSync: (candidate) => candidate === bashPath,
59+
});
60+
61+
assert.equal(resolved, bashPath);
62+
});
63+
64+
test("Windows Git Bash detection derives bash.exe from git.exe candidates", () => {
65+
const bashPath = "D:\\Tools\\Git\\bin\\bash.exe";
66+
const resolved = resolveWindowsGitBashPath({
67+
findExecutableCandidates: (executable) => (executable === "git" ? ["D:\\Tools\\Git\\cmd\\git.exe"] : []),
68+
findGitExecPath: () => null,
69+
existsSync: (candidate) => candidate === bashPath,
70+
});
71+
72+
assert.equal(resolved, bashPath);
73+
});
74+
75+
test("Windows Git Bash detection skips WSL System32 bash.exe in PATH results", () => {
76+
// When WSL1 is enabled on older Windows 10, C:\Windows\System32\bash.exe
77+
// appears in PATH. That launcher would execute commands inside the Linux
78+
// distro instead of the Windows host, breaking all tool invocations.
79+
// The PATH bash strategy should ignore it and fall through.
80+
const system32Bash = "C:\\Windows\\System32\\bash.exe";
81+
const gitBash = "D:\\Tools\\Git\\bin\\bash.exe";
82+
const resolved = resolveWindowsGitBashPath({
83+
findExecutableCandidates: (executable) =>
84+
executable === "bash" ? [system32Bash] : executable === "git" ? ["D:\\Tools\\Git\\cmd\\git.exe"] : [],
85+
findGitExecPath: () => null,
86+
existsSync: (candidate) => candidate === gitBash,
87+
});
88+
89+
assert.equal(resolved, gitBash);
90+
});
91+
4192
test("File tool path normalization converts Git Bash drive paths on Windows", () => {
4293
assert.equal(
4394
normalizeFilePath("/d/IdeaProjects/guesswho-api/API_DOCUMENTATION.md", "win32"),

0 commit comments

Comments
 (0)