Skip to main content

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}