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