Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 86 additions & 15 deletions src/common/shell-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
);
}

Expand Down Expand Up @@ -145,26 +166,76 @@ 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], {
encoding: "utf8",
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<string>();
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<string>();
Expand Down
51 changes: 51 additions & 0 deletions src/tests/shell-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildDisableExtglobCommand,
getShellKind,
posixPathToWindowsPath,
resolveWindowsGitBashPath,
rewriteWindowsNullRedirect,
windowsPathToPosixPath,
} from "../common/shell-utils";
Expand Down Expand Up @@ -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"),
Expand Down