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