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