aft/windows_shell.rs
1//! Shared Windows shell selection for foreground and background bash commands.
2//!
3//! Mirrors OpenCode's resolver:
4//! 1. `$SHELL` env var (typically points at git-bash on Windows dev setups).
5//! 2. `pwsh.exe` (PowerShell 7+).
6//! 3. `powershell.exe` (Windows PowerShell 5.1).
7//! 4. Git-for-Windows `bash.exe` discovered next to `git` on PATH (catches
8//! users who installed Git for Windows but never set `$SHELL`).
9//! 5. `cmd.exe` (universal floor — always reachable on every Windows SKU).
10//!
11//! POSIX shells (bash, sh, zsh, ksh, dash) are invoked as `<shell> -c <cmd>`
12//! the same way Unix does. PowerShell variants take their `-Command` shape;
13//! cmd.exe takes `/D /C`.
14//!
15//! Compiled on all platforms so the cross-platform retry-decision unit
16//! tests in `commands::bash::try_spawn_with_fallback` (test-only — see the
17//! Windows foreground bash path in `crate::commands::bash`) can run on
18//! macOS/Linux dev machines. The production Windows background spawn path
19//! at `bash_background::registry::detached_shell_command_for` is the live
20//! caller.
21
22#![cfg_attr(not(windows), allow(dead_code))]
23
24use std::path::{Path, PathBuf};
25use std::process::Command;
26use std::sync::OnceLock;
27
28/// POSIX shells that can be invoked as `<shell> -c <command>`. Matches
29/// OpenCode's `POSIX` set in `packages/opencode/src/shell/shell.ts`.
30const POSIX_NAMES: &[&str] = &["bash", "sh", "zsh", "ksh", "dash"];
31
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub(crate) enum WindowsShell {
34 /// PowerShell 7+ (cross-platform). Supports `&&` pipeline operator.
35 Pwsh,
36 /// Windows PowerShell 5.1 (legacy, still default on most Windows desktops
37 /// but **absent on Windows 11 IoT Enterprise LTSC SKUs** — issue #27).
38 /// Does NOT support `&&` in pipelines (PS 7+ only feature).
39 Powershell,
40 /// `cmd.exe` — the universal fallback. Present on every Windows SKU.
41 /// Supports `&&` and `||` natively. Lacks PowerShell's piping/cmdlets but
42 /// handles bash-style chained shell invocations correctly.
43 Cmd,
44 /// User-supplied POSIX shell — typically Git for Windows' bash.exe,
45 /// resolved either from `$SHELL` or auto-detected next to `git` on PATH.
46 /// Invoked as `<binary> -c <command>` exactly like a Unix shell, so
47 /// agents that emit bash-syntax commands (`cmd /c "foo"`, `find . -name`,
48 /// quoting with backslash-escapes, etc.) work the same way they would
49 /// in a real bash session. The string is the absolute path to the binary.
50 Posix(PathBuf),
51}
52
53impl WindowsShell {
54 /// Binary path to spawn. PowerShell/cmd variants resolve via PATH lookup;
55 /// `Posix` carries an already-absolute path resolved at candidate-build
56 /// time so we don't accidentally pick a different bash.exe later.
57 pub(crate) fn binary(&self) -> std::borrow::Cow<'_, str> {
58 match self {
59 WindowsShell::Pwsh => std::borrow::Cow::Borrowed("pwsh.exe"),
60 WindowsShell::Powershell => std::borrow::Cow::Borrowed("powershell.exe"),
61 WindowsShell::Cmd => std::borrow::Cow::Borrowed("cmd.exe"),
62 WindowsShell::Posix(path) => std::borrow::Cow::Owned(path.display().to_string()),
63 }
64 }
65
66 /// Argument vector to pass alongside the user's command string.
67 /// PowerShell variants take `-Command <string>`; cmd takes `/D /C <string>`
68 /// (`/D` disables AutoRun macros that could otherwise inject env-trust
69 /// behavior into our isolated invocation); POSIX shells take `-c <string>`.
70 pub(crate) fn args<'a>(&'a self, command: &'a str) -> Vec<&'a str> {
71 match self {
72 WindowsShell::Pwsh | WindowsShell::Powershell => vec![
73 "-NoLogo",
74 "-NoProfile",
75 "-NonInteractive",
76 "-ExecutionPolicy",
77 "Bypass",
78 "-Command",
79 command,
80 ],
81 WindowsShell::Cmd => vec!["/D", "/C", command],
82 WindowsShell::Posix(_) => vec!["-c", command],
83 }
84 }
85
86 #[allow(dead_code)]
87 pub(crate) fn command(&self, command: &str) -> Command {
88 let mut cmd = Command::new(self.binary().as_ref());
89 cmd.args(self.args(command));
90 cmd
91 }
92
93 /// Args for invoking a wrapper file under a PTY-attached shell.
94 /// Returns owned strings so callers can pass temporary wrapper paths.
95 pub(crate) fn pty_wrapper_args(&self, wrapper_path: &Path) -> Vec<String> {
96 match self {
97 WindowsShell::Cmd => vec!["/c".into(), wrapper_path.display().to_string()],
98 WindowsShell::Pwsh | WindowsShell::Powershell => vec![
99 "-NoProfile".into(),
100 "-NonInteractive".into(),
101 "-File".into(),
102 wrapper_path.display().to_string(),
103 ],
104 WindowsShell::Posix(_) => vec![wrapper_path.display().to_string()],
105 }
106 }
107
108 /// Build a `Command` that runs the background wrapper script.
109 ///
110 /// Production background bash now writes cmd wrappers to `.bat` files and
111 /// invokes them without delayed expansion, so paths containing `!` remain
112 /// literal. This helper is retained for tests around legacy inline shapes.
113 ///
114 /// For foreground bash, callers should use [`Self::command`] instead;
115 /// `/V:ON` would change the semantics of user commands containing `!`
116 /// (which would otherwise be passed through literally to the user).
117 // No longer called by production bg-bash (which writes the wrapper
118 // to a temp file and invokes via `-File` / `cmd /C path`), but kept
119 // for tests that exercise the shell-arg shape directly.
120 #[allow(dead_code)]
121 pub(crate) fn bg_command(&self, wrapper: &str) -> Command {
122 let binary = self.binary();
123 let mut cmd = Command::new(binary.as_ref());
124 // PowerShell variants accept the wrapper string directly via
125 // `-Command`; the shell's `-Command` parser is generally happy
126 // with embedded quotes when the script doesn't contain literal
127 // `"` (we use only single quotes in the PS wrapper for that
128 // reason — see `wrapper_script` for `Pwsh|Powershell`).
129 //
130 // For cmd.exe the wrapper contains `cmd_quote`-quoted paths
131 // which CAN survive cmd's /C parser, but only if we add `/S`
132 // to enable simple-quote-stripping mode. Even with /S the
133 // interaction with Rust's std-lib argument quoting is fragile,
134 // so we rely on `args()` for cmd and live with the constraints.
135 //
136 // `/D` skips AutoRun macros; `/S` enables simple quote-stripping.
137 //
138 // POSIX shells (git-bash etc.) take `-c <wrapper>` and execute
139 // the wrapper as a normal shell script — the wrapper's `trap` and
140 // `printf "$?"` mechanics are POSIX-standard, so no special flags.
141 match self {
142 WindowsShell::Pwsh | WindowsShell::Powershell => {
143 cmd.args(self.args(wrapper));
144 }
145 WindowsShell::Cmd => {
146 cmd.args(["/D", "/S", "/C", wrapper]);
147 }
148 WindowsShell::Posix(_) => {
149 cmd.args(["-c", wrapper]);
150 }
151 }
152 cmd
153 }
154
155 /// Wrap a background command so shell termination writes an exit marker.
156 /// The marker is written via temp-file + rename for PowerShell variants and
157 /// via `move /Y` for cmd.exe, matching the Unix background wrapper contract.
158 pub(crate) fn wrapper_script(&self, command: &str, exit_path: &Path) -> String {
159 match self {
160 WindowsShell::Pwsh | WindowsShell::Powershell => {
161 let exit_path = powershell_single_quote(&exit_path.display().to_string());
162 let command = powershell_single_quote(command);
163 // The wrapper itself runs as a PowerShell script (invoked
164 // via `pwsh -File <path>` by `detached_shell_command_for`),
165 // so we execute the user command directly with `Invoke-Expression`
166 // instead of nesting another shell. Earlier versions wrapped
167 // the user command in an inner `& 'pwsh.exe' -Command ...`
168 // which caused PS-on-PS recursion that silently produced
169 // empty output on Windows 11 (likely a console-host issue
170 // with nested non-interactive pwsh sessions).
171 //
172 // CRITICAL: this script must contain NO literal double-quote
173 // characters. Inner `"` would break the outer-shell parse on
174 // some Windows configurations even with `-File`. We use only
175 // single-quoted strings and string concat (`+`) for any
176 // interpolation needs.
177 format!(
178 concat!(
179 "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ",
180 "$OutputEncoding = [Console]::OutputEncoding; ",
181 "$exitPath = {exit_path}; ",
182 "$tmpPath = $exitPath + '.tmp.' + $PID; ",
183 "$global:LASTEXITCODE = $null; ",
184 "Invoke-Expression {command}; ",
185 "$success = $?; ",
186 "$nativeCode = $global:LASTEXITCODE; ",
187 "if ($null -ne $nativeCode) {{ $code = [int]$nativeCode }} ",
188 "elseif ($success) {{ $code = 0 }} ",
189 "else {{ $code = 1 }}; ",
190 "[System.IO.File]::WriteAllText($tmpPath, [string]$code); ",
191 "Move-Item -LiteralPath $tmpPath -Destination $exitPath -Force; ",
192 "exit $code"
193 ),
194 exit_path = exit_path,
195 command = command
196 )
197 }
198 WindowsShell::Cmd => {
199 // This body is written to a `.bat` file and invoked as
200 // `cmd /D /C <wrapper.bat>`. Batch files expand `%ERRORLEVEL%`
201 // per line, so we do not need `/V:ON` delayed expansion; paths
202 // containing literal `!` survive unchanged.
203 let tmp_path = format!("{}.tmp", exit_path.display());
204 format!(
205 concat!(
206 "@echo off\r\n",
207 "{command}\r\n",
208 "set CODE=%ERRORLEVEL%\r\n",
209 "echo %CODE% > {tmp}\r\n",
210 "move /Y {tmp} {exit} > nul\r\n",
211 "exit /B %CODE%\r\n"
212 ),
213 command = command,
214 tmp = cmd_quote(&tmp_path),
215 exit = cmd_quote(&exit_path.display().to_string())
216 )
217 }
218 WindowsShell::Posix(shell_path) => {
219 // git-bash and friends speak POSIX, so the same temp-file +
220 // mv pattern the Unix bg-bash wrapper uses applies here. The
221 // wrapper writes the user command's $? to a temp file and
222 // atomically renames it into place so partial writes are
223 // never observable. Single-quote the user command to defang
224 // any embedded `;`, `&`, or `$` — POSIX single-quotes don't
225 // interpret anything except `'` itself, which we escape via
226 // the `'\''` close-and-reopen idiom.
227 let exit_str = exit_path.display().to_string();
228 let tmp_path = format!("{}.tmp", exit_str);
229 format!(
230 "{} -c {} ; printf '%s' \"$?\" > {} && mv {} {}",
231 posix_single_quote(&shell_path.display().to_string()),
232 posix_single_quote(command),
233 posix_single_quote(&tmp_path),
234 posix_single_quote(&tmp_path),
235 posix_single_quote(&exit_str),
236 )
237 }
238 }
239 }
240
241 pub(crate) fn wrapper_script_bytes(&self, command: &str, exit_path: &Path) -> Vec<u8> {
242 let script = self.wrapper_script(command, exit_path);
243 match self {
244 WindowsShell::Pwsh | WindowsShell::Powershell => {
245 let mut bytes = vec![0xEF, 0xBB, 0xBF];
246 bytes.extend_from_slice(script.as_bytes());
247 bytes
248 }
249 WindowsShell::Cmd | WindowsShell::Posix(_) => script.into_bytes(),
250 }
251 }
252}
253
254/// Resolve which Windows shell to use for `bash` invocations.
255///
256/// Cached after the first resolve to avoid repeated PATH probes — the user's
257/// installed shells don't change mid-session, so a static cache is safe and
258/// keeps bash dispatch fast.
259///
260/// **Note:** PATH probe via `which::which` can disagree with what
261/// `Command::spawn` actually sees at runtime — antivirus / AppLocker rules,
262/// PATH inheritance gaps in the spawning host, or sandbox flags can make
263/// a binary "exist" to `which` but fail to spawn with NotFound. Foreground
264/// bash uses [`shell_candidates`] + runtime retry to defend against this;
265/// callers that take this single-result API are accepting the cached
266/// outcome at face value.
267// No longer called by production bg-bash (the new path uses
268// `shell_candidates()` with retry directly). Kept for potential future
269// use and for parity with the foreground spawn loop.
270#[allow(dead_code)]
271pub(crate) fn resolve_windows_shell() -> WindowsShell {
272 shell_candidates()
273 .first()
274 .cloned()
275 .unwrap_or(WindowsShell::Cmd)
276}
277
278/// All Windows shells that the PATH probe believes are reachable, returned
279/// in priority order. Always non-empty on Windows because cmd.exe is the
280/// floor. Order:
281///
282/// 1. `$SHELL` env var (typically points at git-bash on Windows dev setups).
283/// 2. `pwsh.exe`.
284/// 3. `powershell.exe`.
285/// 4. Git-for-Windows `bash.exe` discovered next to `git` on PATH.
286/// 5. `cmd.exe`.
287///
288/// Used by the foreground bash spawn site to retry with the next candidate
289/// if the first one fails to spawn at runtime. Cached after the first
290/// resolve.
291pub(crate) fn shell_candidates() -> Vec<WindowsShell> {
292 static CACHED: OnceLock<Vec<WindowsShell>> = OnceLock::new();
293 CACHED
294 .get_or_init(|| {
295 shell_candidates_with(
296 |binary| which::which(binary).ok(),
297 || std::env::var_os("SHELL").map(PathBuf::from),
298 )
299 })
300 .clone()
301}
302
303/// Test seam for [`shell_candidates`]. The two closures let unit tests inject
304/// a fake `which`-like resolver and a fake `$SHELL` env value.
305///
306/// `which_for(binary)` should return `Some(absolute_path)` if the binary is
307/// reachable, `None` otherwise — matching the contract of `which::which`.
308pub(crate) fn shell_candidates_with<W, S>(which_for: W, shell_env: S) -> Vec<WindowsShell>
309where
310 W: Fn(&str) -> Option<PathBuf>,
311 S: FnOnce() -> Option<PathBuf>,
312{
313 let mut candidates: Vec<WindowsShell> = Vec::with_capacity(5);
314
315 // 1. $SHELL env var — typically points at git-bash on Windows dev
316 // setups (`/c/Program Files/Git/bin/bash.exe` style or a normal
317 // Windows path). Mirrors OpenCode's preferred() resolution.
318 // Only honored when the named binary is recognized as POSIX
319 // (bash/sh/zsh/ksh/dash) — we don't want SHELL=cmd.exe pinning us
320 // to cmd when the user already gets cmd as the floor candidate.
321 if let Some(shell_path) = shell_env() {
322 if let Some(resolved) = resolve_user_shell(&shell_path, &which_for) {
323 crate::slog_info!(
324 "bash candidate: $SHELL = {} (POSIX, invoked as -c)",
325 resolved.display()
326 );
327 candidates.push(WindowsShell::Posix(resolved));
328 }
329 }
330
331 // 2-3. PowerShell variants.
332 if which_for("pwsh.exe").is_some() {
333 crate::slog_info!(
334 "bash candidate: pwsh.exe (PowerShell 7+; supports && pipeline operator)"
335 );
336 candidates.push(WindowsShell::Pwsh);
337 }
338 if which_for("powershell.exe").is_some() {
339 crate::slog_info!("bash candidate: powershell.exe (Windows PowerShell 5.1; && in pipelines unsupported, will surface as parse error)");
340 candidates.push(WindowsShell::Powershell);
341 }
342
343 // 4. Git for Windows auto-detect — find bash.exe next to git on PATH.
344 // Catches the common case of "user installed Git for Windows but
345 // didn't set $SHELL". Skipped when $SHELL already produced a POSIX
346 // candidate (no point adding the same git-bash twice).
347 let already_posix = candidates
348 .iter()
349 .any(|c| matches!(c, WindowsShell::Posix(_)));
350 if !already_posix {
351 if let Some(git_bash) = locate_git_bash(&which_for) {
352 crate::slog_info!(
353 "bash candidate: git-bash auto-detected at {} (POSIX, invoked as -c)",
354 git_bash.display()
355 );
356 candidates.push(WindowsShell::Posix(git_bash));
357 }
358 }
359
360 // 5. cmd.exe is always added as the floor, regardless of PATH probe
361 // result. It lives in a Windows search-path location that PATH
362 // inheritance issues, ASR rules, and sandboxing generally cannot
363 // remove. Without this floor, foreground bash retry would have
364 // nowhere to fall back to when other shells fail to spawn at runtime.
365 candidates.push(WindowsShell::Cmd);
366
367 let only_cmd = candidates.len() == 1;
368 if only_cmd {
369 crate::slog_warn!(
370 "No bash, PowerShell, or git-bash is reachable from this \
371 aft process — using cmd.exe only. This can occur even when \
372 PowerShell is installed if PATH inheritance is restricted, \
373 antivirus / AppLocker / Defender ASR rules block PowerShell as a \
374 child process, or you're on a stripped Windows SKU. Bash-style \
375 commands using && and || still work; PowerShell-only cmdlets and \
376 POSIX-only commands will not. Details: \
377 https://github.com/cortexkit/aft/issues/27"
378 );
379 }
380 candidates
381}
382
383/// Resolve a `$SHELL` value into an absolute path to a POSIX shell binary,
384/// or `None` if the value is unusable on Windows. Handles three input
385/// shapes that show up in the wild:
386///
387/// - Full Windows path: `C:\Program Files\Git\bin\bash.exe`
388/// - MSYS/git-bash style: `/c/Program Files/Git/bin/bash.exe` or `/usr/bin/bash`
389/// - Bare name: `bash` or `bash.exe` (resolve via `which`)
390///
391/// Returns `None` if the resolved binary's filename isn't in `POSIX_NAMES`,
392/// so that someone with `SHELL=cmd.exe` doesn't accidentally pin us to a
393/// `Posix(cmd.exe)` invocation that breaks the `-c` contract.
394fn resolve_user_shell<W>(raw: &Path, which_for: &W) -> Option<PathBuf>
395where
396 W: Fn(&str) -> Option<PathBuf>,
397{
398 // Convert MSYS-style /c/foo/bar paths to C:\foo\bar so std::fs::metadata
399 // and Command::new can find them. Pure Windows paths and POSIX paths on
400 // a MSYS root pass through with /-to-\ normalization.
401 let resolved = normalize_shell_path(raw);
402
403 // If the (possibly-normalized) path is absolute and exists on disk,
404 // use it as-is. Otherwise treat it as a bare name and try PATH lookup.
405 let candidate = if resolved.is_absolute() && resolved.exists() {
406 resolved
407 } else {
408 let name = resolved.file_name()?.to_str()?.to_string();
409 which_for(&name)?
410 };
411
412 if !is_posix_shell_name(&candidate) {
413 crate::slog_info!(
414 "$SHELL points at {} which isn't a recognized POSIX shell; \
415 falling back to PowerShell/cmd resolution.",
416 candidate.display()
417 );
418 return None;
419 }
420 Some(candidate)
421}
422
423/// Look for git-bash next to `git` on PATH. Mirrors OpenCode's `gitbash()`:
424/// resolves `git`, then checks `<git_dir>/../../bin/bash.exe`. Returns
425/// `None` if git isn't on PATH, the expected bash.exe doesn't exist, or
426/// the file is empty.
427fn locate_git_bash<W>(which_for: &W) -> Option<PathBuf>
428where
429 W: Fn(&str) -> Option<PathBuf>,
430{
431 let git = which_for("git.exe").or_else(|| which_for("git"))?;
432 // git.exe typically lives at <install>/cmd/git.exe; bash.exe lives at
433 // <install>/bin/bash.exe. The two `parent()` calls walk up from
434 // `cmd/git.exe` to `<install>`, then we descend into `bin/bash.exe`.
435 let candidate = git.parent()?.parent()?.join("bin").join("bash.exe");
436 let metadata = std::fs::metadata(&candidate).ok()?;
437 if metadata.len() == 0 {
438 return None;
439 }
440 Some(candidate)
441}
442
443/// Normalize an MSYS / git-bash POSIX path to a Windows path, leaving
444/// already-Windows paths and bare names alone. This mirrors the relevant
445/// subset of OpenCode's `windowsPath()` for `$SHELL` values.
446fn normalize_shell_path(raw: &Path) -> PathBuf {
447 let s = raw.to_string_lossy();
448
449 // MSYS drive-letter form: /c/Foo/Bar -> C:\Foo\Bar
450 if let Some(rest) = s.strip_prefix('/') {
451 if let Some((drive, after)) = rest.split_once('/') {
452 if drive.len() == 1
453 && drive
454 .chars()
455 .next()
456 .is_some_and(|c| c.is_ascii_alphabetic())
457 {
458 let drive_upper = drive.to_uppercase();
459 let win = format!("{}:\\{}", drive_upper, after.replace('/', "\\"));
460 return PathBuf::from(win);
461 }
462 }
463 }
464
465 PathBuf::from(s.as_ref())
466}
467
468/// True when the file name (without extension) is in `POSIX_NAMES`.
469fn is_posix_shell_name(path: &Path) -> bool {
470 let stem = path
471 .file_stem()
472 .and_then(|s| s.to_str())
473 .unwrap_or("")
474 .to_lowercase();
475 POSIX_NAMES.iter().any(|name| *name == stem)
476}
477
478fn powershell_single_quote(value: &str) -> String {
479 format!("'{}'", value.replace('\'', "''"))
480}
481
482/// Single-quote a value for POSIX `sh -c`, escaping inner single quotes via
483/// the standard `'\''` close-and-reopen idiom. Used by the bg-bash wrapper
484/// for [`WindowsShell::Posix`] (git-bash) and matches the Unix wrapper's
485/// quoting contract.
486#[cfg_attr(not(windows), allow(dead_code))]
487fn posix_single_quote(value: &str) -> String {
488 format!("'{}'", value.replace('\'', "'\\''"))
489}
490
491// Used by `wrapper_script` for `WindowsShell::Cmd`; that wrapper is
492// only invoked from `bash_background::registry::detached_shell_command_for`
493// which is `#[cfg(windows)]`. The function compiles on all platforms so
494// `wrapper_script` stays cross-platform-testable.
495#[cfg_attr(not(windows), allow(dead_code))]
496fn cmd_quote(value: &str) -> String {
497 format!("\"{}\"", value.replace('"', "\"\""))
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 /// Helper: build a `which`-like closure that returns Some for the
505 /// listed binaries (mapping each to a synthetic absolute path) and
506 /// None for everything else. The synthetic path layout matches a
507 /// realistic Git for Windows install when `git.exe` is present,
508 /// so [`locate_git_bash`] can synthesize a sibling bash.exe path —
509 /// but the returned path won't exist on disk, so `locate_git_bash`
510 /// will bail at the metadata check, which is what the no-Posix-via-
511 /// auto-detect tests actually want.
512 fn fake_which(binaries: Vec<&'static str>) -> impl Fn(&str) -> Option<PathBuf> {
513 move |query| {
514 if binaries.contains(&query) {
515 match query {
516 "git.exe" | "git" => Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe")),
517 _ => Some(PathBuf::from(format!(r"C:\fake\{}", query))),
518 }
519 } else {
520 None
521 }
522 }
523 }
524
525 #[test]
526 fn pty_wrapper_args_are_owned_and_shell_specific() {
527 let wrapper = PathBuf::from(r"C:\tmp\task.bat");
528 assert_eq!(
529 WindowsShell::Cmd.pty_wrapper_args(&wrapper),
530 vec!["/c".to_string(), wrapper.display().to_string()]
531 );
532 assert_eq!(
533 WindowsShell::Pwsh.pty_wrapper_args(&wrapper),
534 vec![
535 "-NoProfile".to_string(),
536 "-NonInteractive".to_string(),
537 "-File".to_string(),
538 wrapper.display().to_string(),
539 ]
540 );
541 assert_eq!(
542 WindowsShell::Posix(PathBuf::from(r"C:\Git\bin\bash.exe")).pty_wrapper_args(&wrapper),
543 vec![wrapper.display().to_string()]
544 );
545 }
546
547 // ---------------------------------------------------------------
548 // Fix for user report: $SHELL must be respected on Windows so
549 // git-bash (and other POSIX shells) can run agent-emitted bash
550 // syntax instead of getting routed to PowerShell where escaping
551 // breaks. Mirrors OpenCode's behavior.
552 // ---------------------------------------------------------------
553
554 #[test]
555 fn user_shell_pointing_at_bash_wins_over_powershell() {
556 // SHELL=C:\Program Files\Git\bin\bash.exe
557 // pwsh.exe also reachable.
558 // Expect: Posix(bash.exe) is the first candidate, pwsh second.
559 let tmp = tempfile::tempdir().expect("tempdir");
560 let bash = tmp.path().join("bash.exe");
561 std::fs::write(&bash, b"shebang").unwrap();
562
563 let candidates = shell_candidates_with(fake_which(vec!["pwsh.exe"]), || Some(bash.clone()));
564
565 assert!(matches!(candidates[0], WindowsShell::Posix(_)));
566 if let WindowsShell::Posix(p) = &candidates[0] {
567 assert_eq!(p, &bash);
568 }
569 assert_eq!(candidates[1], WindowsShell::Pwsh);
570 }
571
572 #[test]
573 fn user_shell_pointing_at_non_posix_binary_is_ignored() {
574 // SHELL=C:\Windows\System32\cmd.exe — not in POSIX_NAMES, so
575 // we should fall back to PowerShell/cmd resolution.
576 let tmp = tempfile::tempdir().expect("tempdir");
577 let cmd = tmp.path().join("cmd.exe");
578 std::fs::write(&cmd, b"").unwrap();
579
580 let candidates = shell_candidates_with(fake_which(vec!["pwsh.exe"]), || Some(cmd));
581
582 // No Posix candidate; pwsh wins.
583 assert!(!candidates
584 .iter()
585 .any(|c| matches!(c, WindowsShell::Posix(_))));
586 assert_eq!(candidates[0], WindowsShell::Pwsh);
587 }
588
589 #[test]
590 fn user_shell_msys_drive_letter_path_is_normalized() {
591 // SHELL=/c/Program Files/Git/bin/bash.exe — git-bash style.
592 // Without normalization this won't exist at all, so the
593 // resolver should at least *try* the normalized form before
594 // falling through.
595 //
596 // We can't easily fake an existing file at C:\... in a unit
597 // test, so we directly assert the normalization output here.
598 let raw = PathBuf::from("/c/Program Files/Git/bin/bash.exe");
599 let normalized = normalize_shell_path(&raw);
600 assert_eq!(
601 normalized,
602 PathBuf::from(r"C:\Program Files\Git\bin\bash.exe")
603 );
604 }
605
606 #[test]
607 fn user_shell_already_windows_path_passes_through() {
608 let raw = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
609 let normalized = normalize_shell_path(&raw);
610 assert_eq!(normalized, raw);
611 }
612
613 /// Note: this test runs on every platform but uses platform-native
614 /// path separators because `Path::file_stem()` only recognizes the
615 /// host OS's separator. On macOS/Linux that means a forward-slash
616 /// fake path (`/fake/bash`); on Windows the equivalent backslash
617 /// path. The production code only runs on Windows where backslash
618 /// works correctly, so the test's job is to verify the resolution
619 /// flow, not the path syntax.
620 #[test]
621 fn user_shell_bare_name_resolves_via_which() {
622 // SHELL=bash → not absolute → which("bash") returns the fake
623 // resolver's path → recognized as POSIX.
624 #[cfg(unix)]
625 let expected = PathBuf::from("/fake/bash");
626 #[cfg(windows)]
627 let expected = PathBuf::from(r"C:\fake\bash");
628
629 // Pre-translate the fake_which return so it uses the host's
630 // separator. We can't share fake_which here because that helper
631 // is hard-coded to Windows-style paths.
632 let expected_clone = expected.clone();
633 let which_for = move |query: &str| -> Option<PathBuf> {
634 if query == "bash" {
635 Some(expected_clone.clone())
636 } else {
637 None
638 }
639 };
640
641 let candidates = shell_candidates_with(which_for, || Some(PathBuf::from("bash")));
642 assert!(
643 matches!(&candidates[0], WindowsShell::Posix(p) if p == &expected),
644 "expected Posix({}) as first candidate, got {:?}",
645 expected.display(),
646 candidates
647 );
648 }
649
650 #[test]
651 fn no_user_shell_and_no_git_falls_back_to_pwsh_powershell_cmd() {
652 let candidates =
653 shell_candidates_with(fake_which(vec!["pwsh.exe", "powershell.exe"]), || None);
654 assert_eq!(candidates.len(), 3);
655 assert_eq!(candidates[0], WindowsShell::Pwsh);
656 assert_eq!(candidates[1], WindowsShell::Powershell);
657 assert_eq!(candidates[2], WindowsShell::Cmd);
658 }
659
660 #[test]
661 fn cmd_is_always_the_floor() {
662 // Nothing reachable, no $SHELL — only cmd.exe should be in the list.
663 let candidates = shell_candidates_with(|_| None, || None);
664 assert_eq!(candidates, vec![WindowsShell::Cmd]);
665 }
666
667 // ---------------------------------------------------------------
668 // git-bash auto-detect: when $SHELL is unset but the user installed
669 // Git for Windows, we should still pick up the bundled bash.exe.
670 // ---------------------------------------------------------------
671
672 #[test]
673 fn git_bash_auto_detect_when_shell_unset() {
674 let tmp = tempfile::tempdir().expect("tempdir");
675 // Mirror the Git for Windows layout: <root>/cmd/git.exe and
676 // <root>/bin/bash.exe.
677 std::fs::create_dir_all(tmp.path().join("cmd")).unwrap();
678 std::fs::create_dir_all(tmp.path().join("bin")).unwrap();
679 let git = tmp.path().join("cmd").join("git.exe");
680 std::fs::write(&git, b"git").unwrap();
681 let bash = tmp.path().join("bin").join("bash.exe");
682 std::fs::write(&bash, b"shebang").unwrap();
683
684 let which = |query: &str| -> Option<PathBuf> {
685 match query {
686 "git.exe" | "git" => Some(git.clone()),
687 _ => None,
688 }
689 };
690 let candidates = shell_candidates_with(which, || None);
691
692 // First candidate is the auto-detected git-bash.
693 assert!(matches!(&candidates[0], WindowsShell::Posix(p) if p == &bash));
694 // cmd.exe is still the floor.
695 assert_eq!(*candidates.last().unwrap(), WindowsShell::Cmd);
696 }
697
698 #[test]
699 fn git_bash_skipped_when_user_shell_already_posix() {
700 // $SHELL points at git-bash → no need to auto-detect a second
701 // POSIX candidate. The candidate list should not contain two
702 // Posix entries.
703 let tmp = tempfile::tempdir().expect("tempdir");
704 let bash = tmp.path().join("bash.exe");
705 std::fs::write(&bash, b"shebang").unwrap();
706
707 let candidates = shell_candidates_with(
708 // git is reachable, but git-bash should NOT be added because
709 // we already have a Posix from $SHELL.
710 |query: &str| match query {
711 "git.exe" | "git" => Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe")),
712 _ => None,
713 },
714 || Some(bash.clone()),
715 );
716
717 let posix_count = candidates
718 .iter()
719 .filter(|c| matches!(c, WindowsShell::Posix(_)))
720 .count();
721 assert_eq!(
722 posix_count, 1,
723 "exactly one Posix candidate when $SHELL is already set: got {:?}",
724 candidates
725 );
726 }
727
728 // ---------------------------------------------------------------
729 // Spawn-shape tests: Posix(bash) must be invoked as `bash -c <cmd>`
730 // exactly the way Unix bash works.
731 // ---------------------------------------------------------------
732
733 #[test]
734 fn posix_shell_uses_dash_c_invocation() {
735 let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
736 let shell = WindowsShell::Posix(bash);
737 let args = shell.args("ls -la /tmp");
738 assert_eq!(args, vec!["-c", "ls -la /tmp"]);
739 }
740
741 #[test]
742 fn posix_shell_binary_returns_full_path() {
743 let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
744 let shell = WindowsShell::Posix(bash.clone());
745 assert_eq!(shell.binary().as_ref(), &bash.display().to_string());
746 }
747
748 #[test]
749 fn pwsh_args_unchanged() {
750 // Regression guard: refactor must not have altered PowerShell
751 // arg shape.
752 let shell = WindowsShell::Pwsh;
753 let args = shell.args("Get-ChildItem");
754 assert_eq!(
755 args,
756 vec![
757 "-NoLogo",
758 "-NoProfile",
759 "-NonInteractive",
760 "-ExecutionPolicy",
761 "Bypass",
762 "-Command",
763 "Get-ChildItem"
764 ]
765 );
766 }
767
768 #[test]
769 fn cmd_args_unchanged() {
770 let shell = WindowsShell::Cmd;
771 let args = shell.args("dir");
772 assert_eq!(args, vec!["/D", "/C", "dir"]);
773 }
774
775 // ---------------------------------------------------------------
776 // POSIX wrapper script: bg-bash exit-marker contract for git-bash.
777 // ---------------------------------------------------------------
778
779 #[test]
780 fn posix_wrapper_writes_exit_marker_atomically() {
781 let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
782 let shell = WindowsShell::Posix(bash);
783 let script = shell.wrapper_script("echo hi", Path::new(r"C:\Temp\bash.exit"));
784 // The F2 wrapper invokes the resolved shell path directly (not a bare
785 // `sh -c`), so users get bash semantics (`[[ ]]`, arrays, pipefail)
786 // rather than dash. It then captures `$?` via `printf` into a tmp file
787 // and `mv`s atomically into place.
788 assert!(
789 script.contains(r"'C:\Program Files\Git\bin\bash.exe' -c 'echo hi'"),
790 "wrapper must invoke the resolved shell directly: {script}",
791 );
792 assert!(script.contains("printf '%s' \"$?\""), "{script}");
793 assert!(script.contains("mv "), "{script}");
794 assert!(script.contains(r"C:\Temp\bash.exit"), "{script}");
795 assert!(script.contains(r"C:\Temp\bash.exit.tmp"), "{script}");
796 }
797
798 #[test]
799 fn posix_wrapper_escapes_embedded_single_quotes() {
800 // User command contains a single quote — wrapper must use the
801 // standard `'\''` close-and-reopen idiom.
802 let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
803 let shell = WindowsShell::Posix(bash);
804 let script = shell.wrapper_script("echo 'hi'", Path::new(r"C:\Temp\bash.exit"));
805 assert!(
806 script.contains(r"'echo '\''hi'\'''"),
807 "embedded single quote must be escaped: got {script}"
808 );
809 }
810
811 // ---------------------------------------------------------------
812 // is_posix_shell_name: case-insensitive, .exe-tolerant lookup.
813 // ---------------------------------------------------------------
814
815 #[test]
816 fn is_posix_shell_name_recognizes_known_shells() {
817 for name in ["bash", "BASH", "bash.exe", "Bash.Exe", "sh", "zsh.exe"] {
818 assert!(
819 is_posix_shell_name(Path::new(name)),
820 "{name} should be POSIX"
821 );
822 }
823 }
824
825 #[test]
826 fn is_posix_shell_name_rejects_non_posix() {
827 for name in ["cmd.exe", "powershell.exe", "pwsh.exe", "fish", "nu.exe"] {
828 assert!(
829 !is_posix_shell_name(Path::new(name)),
830 "{name} must NOT be POSIX"
831 );
832 }
833 }
834}