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