Skip to content

Commit 7e61d95

Browse files
committed
🤖 feat: improve Windows bash runtime detection with WSL support
- Add BashRuntime types (GitBashRuntime, WslRuntime, UnixBashRuntime) - Detect available runtimes: WSL (preferred) and Git for Windows (fallback) - Automatic Windows path translation for WSL (C:\ -> /mnt/c/) - cwd embedded in script via 'cd' since WSL needs Linux paths - Use -- separator to avoid WSL arg parsing issues - Use absolute paths to bash for each runtime - Always wrap commands in bash -c for consistent behavior - Update BashExecutionService, execAsync, and LocalBaseRuntime - Add comprehensive tests for runtime detection and path translation _Generated with mux_
1 parent 1fc5493 commit 7e61d95

File tree

6 files changed

+539
-45
lines changed

6 files changed

+539
-45
lines changed

docs/system-prompt.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,4 @@ You are in a git worktree at ${workspacePath}
6262
}
6363
```
6464

65-
6665
{/* END SYSTEM_PROMPT_DOCS */}

src/node/runtime/LocalBaseRuntime.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type {
1818
} from "./Runtime";
1919
import { RuntimeError as RuntimeErrorClass } from "./Runtime";
2020
import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env";
21-
import { getBashPath } from "@/node/utils/main/bashPath";
21+
import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath";
2222
import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes";
2323
import { DisposableProcess } from "@/node/utils/disposableExec";
2424
import { expandTilde } from "./tildeExpansion";
@@ -67,18 +67,26 @@ export abstract class LocalBaseRuntime implements Runtime {
6767
);
6868
}
6969

70+
// Get spawn config for the preferred bash runtime
71+
// This handles Git for Windows, WSL, and Unix/macOS automatically
72+
// For WSL, paths in the command and cwd are translated to /mnt/... format
73+
const {
74+
command: bashCommand,
75+
args: bashArgs,
76+
cwd: spawnCwd,
77+
} = getPreferredSpawnConfig(command, cwd);
78+
7079
// If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues
7180
// Windows doesn't have nice command, so just spawn bash directly
7281
const isWindows = process.platform === "win32";
73-
const bashPath = getBashPath();
74-
const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath;
82+
const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashCommand;
7583
const spawnArgs =
7684
options.niceness !== undefined && !isWindows
77-
? ["-n", options.niceness.toString(), bashPath, "-c", command]
78-
: ["-c", command];
85+
? ["-n", options.niceness.toString(), bashCommand, ...bashArgs]
86+
: bashArgs;
7987

8088
const childProcess = spawn(spawnCommand, spawnArgs, {
81-
cwd,
89+
cwd: spawnCwd,
8290
env: {
8391
...process.env,
8492
...(options.env ?? {}),
@@ -367,9 +375,16 @@ export abstract class LocalBaseRuntime implements Runtime {
367375
const loggers = createLineBufferedLoggers(initLogger);
368376

369377
return new Promise<void>((resolve) => {
370-
const bashPath = getBashPath();
371-
const proc = spawn(bashPath, ["-c", `"${hookPath}"`], {
372-
cwd: workspacePath,
378+
// Get spawn config for the preferred bash runtime
379+
// For WSL, the hook path and cwd are translated to /mnt/... format
380+
const {
381+
command: bashCommand,
382+
args: bashArgs,
383+
cwd: spawnCwd,
384+
} = getPreferredSpawnConfig(`"${hookPath}"`, workspacePath);
385+
386+
const proc = spawn(bashCommand, bashArgs, {
387+
cwd: spawnCwd,
373388
stdio: ["ignore", "pipe", "pipe"],
374389
env: {
375390
...process.env,

src/node/services/bashExecutionService.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { spawn } from "child_process";
22
import type { ChildProcess } from "child_process";
33
import { log } from "./log";
4-
import { getBashPath } from "@/node/utils/main/bashPath";
4+
import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath";
55

66
/**
77
* Configuration for bash execution
@@ -121,17 +121,25 @@ export class BashExecutionService {
121121
`BashExecutionService: Script: ${script.substring(0, 100)}${script.length > 100 ? "..." : ""}`
122122
);
123123

124+
// Get spawn config for the preferred bash runtime
125+
// This handles Git for Windows, WSL, and Unix/macOS automatically
126+
// For WSL, paths in the script and cwd are translated to /mnt/... format
127+
const {
128+
command: bashCommand,
129+
args: bashArgs,
130+
cwd: spawnCwd,
131+
} = getPreferredSpawnConfig(script, config.cwd);
132+
124133
// Windows doesn't have nice command, so just spawn bash directly
125134
const isWindows = process.platform === "win32";
126-
const bashPath = getBashPath();
127-
const spawnCommand = config.niceness !== undefined && !isWindows ? "nice" : bashPath;
135+
const spawnCommand = config.niceness !== undefined && !isWindows ? "nice" : bashCommand;
128136
const spawnArgs =
129137
config.niceness !== undefined && !isWindows
130-
? ["-n", config.niceness.toString(), bashPath, "-c", script]
131-
: ["-c", script];
138+
? ["-n", config.niceness.toString(), bashCommand, ...bashArgs]
139+
: bashArgs;
132140

133141
const child = spawn(spawnCommand, spawnArgs, {
134-
cwd: config.cwd,
142+
cwd: spawnCwd,
135143
env: this.createBashEnvironment(config.secrets),
136144
stdio: ["ignore", "pipe", "pipe"],
137145
// Spawn as detached process group leader to prevent zombie processes

src/node/utils/disposableExec.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { exec } from "child_process";
1+
import { spawn } from "child_process";
22
import type { ChildProcess } from "child_process";
3+
import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath";
34

45
/**
56
* Disposable wrapper for child processes that ensures immediate cleanup.
@@ -117,12 +118,23 @@ class DisposableExec implements Disposable {
117118
* Execute command with automatic cleanup via `using` declaration.
118119
* Prevents zombie processes by ensuring child is reaped even on error.
119120
*
121+
* Commands are always wrapped in `bash -c` for consistent behavior across platforms.
122+
* On Windows, this uses the detected bash runtime (Git for Windows or WSL).
123+
* For WSL, Windows paths in the command are automatically translated.
124+
*
120125
* @example
121126
* using proc = execAsync("git status");
122127
* const { stdout } = await proc.result;
123128
*/
124129
export function execAsync(command: string): DisposableExec {
125-
const child = exec(command);
130+
// Wrap command in bash -c for consistent cross-platform behavior
131+
// For WSL, this also translates Windows paths to /mnt/... format
132+
const { command: bashCmd, args } = getPreferredSpawnConfig(command);
133+
const child = spawn(bashCmd, args, {
134+
stdio: ["ignore", "pipe", "pipe"],
135+
// Prevent console window from appearing on Windows
136+
windowsHide: true,
137+
});
126138
const promise = new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
127139
let stdout = "";
128140
let stderr = "";
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { describe, it, expect } from "bun:test";
2+
import {
3+
detectBashRuntimes,
4+
getSpawnConfig,
5+
getPreferredSpawnConfig,
6+
isBashAvailable,
7+
windowsToWslPath,
8+
translateWindowsPathsInCommand,
9+
type BashRuntime,
10+
} from "./bashPath";
11+
12+
describe("bashPath", () => {
13+
describe("detectBashRuntimes", () => {
14+
it("should detect at least one runtime", () => {
15+
const runtimes = detectBashRuntimes();
16+
expect(runtimes.available.length).toBeGreaterThan(0);
17+
expect(runtimes.preferred).toBeDefined();
18+
});
19+
20+
it("should return unix runtime on non-Windows platforms", () => {
21+
// This test runs on Linux/macOS CI
22+
if (process.platform !== "win32") {
23+
const runtimes = detectBashRuntimes();
24+
expect(runtimes.preferred.type).toBe("unix");
25+
expect(runtimes.available).toContainEqual({ type: "unix" });
26+
}
27+
});
28+
29+
it("should cache results", () => {
30+
const first = detectBashRuntimes();
31+
const second = detectBashRuntimes();
32+
expect(first).toBe(second); // Same object reference
33+
});
34+
});
35+
36+
describe("getSpawnConfig", () => {
37+
it("should generate correct config for unix runtime", () => {
38+
const runtime: BashRuntime = { type: "unix" };
39+
const config = getSpawnConfig(runtime, "echo hello");
40+
41+
expect(config.command).toBe("bash");
42+
expect(config.args).toEqual(["-c", "echo hello"]);
43+
});
44+
45+
it("should generate correct config for git-bash runtime", () => {
46+
const runtime: BashRuntime = {
47+
type: "git-bash",
48+
bashPath: "C:\\Program Files\\Git\\bin\\bash.exe",
49+
};
50+
const config = getSpawnConfig(runtime, "echo hello");
51+
52+
expect(config.command).toBe("C:\\Program Files\\Git\\bin\\bash.exe");
53+
expect(config.args).toEqual(["-c", "echo hello"]);
54+
});
55+
56+
it("should generate correct config for wsl runtime with distro", () => {
57+
const runtime: BashRuntime = { type: "wsl", distro: "Ubuntu" };
58+
const config = getSpawnConfig(runtime, "echo hello");
59+
60+
expect(config.command).toBe("wsl");
61+
expect(config.args).toContain("-d");
62+
expect(config.args).toContain("Ubuntu");
63+
expect(config.args).toContain("--");
64+
expect(config.args).toContain("bash");
65+
expect(config.args).toContain("-c");
66+
expect(config.args[config.args.length - 1]).toContain("echo hello");
67+
});
68+
69+
it("should generate correct config for wsl runtime without distro", () => {
70+
const runtime: BashRuntime = { type: "wsl", distro: null };
71+
const config = getSpawnConfig(runtime, "echo hello");
72+
73+
expect(config.command).toBe("wsl");
74+
expect(config.args).not.toContain("-d");
75+
expect(config.args).toContain("--");
76+
expect(config.args).toContain("bash");
77+
expect(config.args[config.args.length - 1]).toContain("echo hello");
78+
});
79+
80+
it("should handle complex scripts with quotes and special characters", () => {
81+
const runtime: BashRuntime = { type: "unix" };
82+
const script = 'git commit -m "test message" && echo "done"';
83+
const config = getSpawnConfig(runtime, script);
84+
85+
expect(config.args[1]).toBe(script);
86+
});
87+
});
88+
89+
describe("getPreferredSpawnConfig", () => {
90+
it("should return valid spawn config", () => {
91+
const config = getPreferredSpawnConfig("ls -la");
92+
93+
expect(config.command).toBeDefined();
94+
expect(config.args).toBeArray();
95+
expect(config.args.length).toBeGreaterThan(0);
96+
});
97+
98+
it("should include the script in args", () => {
99+
const script = "git status --porcelain";
100+
const config = getPreferredSpawnConfig(script);
101+
102+
// Script should be in args (either directly or as part of -c arg)
103+
expect(config.args.join(" ")).toContain(script);
104+
});
105+
});
106+
107+
describe("isBashAvailable", () => {
108+
it("should return true when bash is available", () => {
109+
// On CI (Linux), bash should always be available
110+
if (process.platform !== "win32") {
111+
expect(isBashAvailable()).toBe(true);
112+
}
113+
});
114+
});
115+
116+
describe("windowsToWslPath", () => {
117+
it("should convert C:\\ paths to /mnt/c/", () => {
118+
expect(windowsToWslPath("C:\\Users\\micha\\source\\mux")).toBe(
119+
"/mnt/c/Users/micha/source/mux"
120+
);
121+
});
122+
123+
it("should convert D:\\ paths to /mnt/d/", () => {
124+
expect(windowsToWslPath("D:\\Projects\\myapp")).toBe("/mnt/d/Projects/myapp");
125+
});
126+
127+
it("should handle lowercase drive letters", () => {
128+
expect(windowsToWslPath("c:\\temp")).toBe("/mnt/c/temp");
129+
});
130+
131+
it("should handle forward slashes in Windows paths", () => {
132+
expect(windowsToWslPath("C:/Users/micha")).toBe("/mnt/c/Users/micha");
133+
});
134+
135+
it("should return non-Windows paths unchanged", () => {
136+
expect(windowsToWslPath("/home/user")).toBe("/home/user");
137+
expect(windowsToWslPath("relative/path")).toBe("relative/path");
138+
});
139+
140+
it("should handle paths with spaces", () => {
141+
expect(windowsToWslPath("C:\\Program Files\\Git")).toBe("/mnt/c/Program Files/Git");
142+
});
143+
});
144+
145+
describe("translateWindowsPathsInCommand", () => {
146+
it("should translate unquoted paths", () => {
147+
expect(translateWindowsPathsInCommand("cd C:\\Users\\micha")).toBe("cd /mnt/c/Users/micha");
148+
});
149+
150+
it("should translate double-quoted paths", () => {
151+
expect(translateWindowsPathsInCommand('git -C "C:\\Users\\micha\\mux" status')).toBe(
152+
'git -C "/mnt/c/Users/micha/mux" status'
153+
);
154+
});
155+
156+
it("should translate single-quoted paths", () => {
157+
expect(translateWindowsPathsInCommand("ls 'D:\\Projects'")).toBe("ls '/mnt/d/Projects'");
158+
});
159+
160+
it("should translate multiple paths in one command", () => {
161+
expect(translateWindowsPathsInCommand('cp "C:\\src" "D:\\dest"')).toBe(
162+
'cp "/mnt/c/src" "/mnt/d/dest"'
163+
);
164+
});
165+
166+
it("should leave non-Windows paths alone", () => {
167+
const cmd = "ls /home/user && cat file.txt";
168+
expect(translateWindowsPathsInCommand(cmd)).toBe(cmd);
169+
});
170+
171+
it("should handle git -C commands", () => {
172+
expect(
173+
translateWindowsPathsInCommand('git -C "C:\\Users\\micha\\source\\mux" worktree list')
174+
).toBe('git -C "/mnt/c/Users/micha/source/mux" worktree list');
175+
});
176+
});
177+
178+
describe("getSpawnConfig with WSL path translation", () => {
179+
it("should translate cwd for WSL runtime", () => {
180+
const runtime: BashRuntime = { type: "wsl", distro: "Ubuntu" };
181+
const config = getSpawnConfig(runtime, "ls", "C:\\Users\\micha");
182+
183+
// cwd is embedded in the script via 'cd' command
184+
expect(config.cwd).toBeUndefined();
185+
// The translated cwd should be in the bash script
186+
const bashScript = config.args[config.args.length - 1];
187+
expect(bashScript).toContain("/mnt/c/Users/micha");
188+
});
189+
190+
it("should translate paths in script for WSL runtime", () => {
191+
const runtime: BashRuntime = { type: "wsl", distro: null };
192+
const config = getSpawnConfig(runtime, 'git -C "C:\\Projects\\app" status');
193+
194+
// The script has translated paths
195+
const bashScript = config.args[config.args.length - 1];
196+
expect(bashScript).toContain("/mnt/c/Projects/app");
197+
});
198+
199+
it("should not translate paths for git-bash runtime", () => {
200+
const runtime: BashRuntime = {
201+
type: "git-bash",
202+
bashPath: "C:\\Program Files\\Git\\bin\\bash.exe",
203+
};
204+
const config = getSpawnConfig(runtime, 'git -C "C:\\Projects" status', "C:\\Projects");
205+
206+
expect(config.cwd).toBe("C:\\Projects");
207+
expect(config.args[1]).toBe('git -C "C:\\Projects" status');
208+
});
209+
210+
it("should not translate paths for unix runtime", () => {
211+
const runtime: BashRuntime = { type: "unix" };
212+
const config = getSpawnConfig(runtime, "ls", "/home/user");
213+
214+
expect(config.cwd).toBe("/home/user");
215+
});
216+
});
217+
});

0 commit comments

Comments
 (0)