From b1dad2480a6f3a7955f7e4690dac3dcc39ac2493 Mon Sep 17 00:00:00 2001 From: GGbond <2256433591@qq.com> Date: Mon, 14 Sep 2026 06:55:57 +0800 Subject: [PATCH] test(scripts): resolve Git Bash instead of the WSL launcher for Windows shell-script tests (#5404) * test(scripts): resolve Git Bash instead of the WSL launcher for Windows shell-script tests * test(scripts): pin Windows shell-discovery rules in unit tests and prefer Git's sh.exe for POSIX-sh tests --- backend/tests/support/shell.py | 122 +++++++++++++++ backend/tests/test_config_version.py | 9 +- backend/tests/test_deploy_uv_extras.py | 31 +++- backend/tests/test_dev_entrypoint.py | 57 ++++++- .../test_docker_sandbox_mode_detection.py | 13 +- backend/tests/test_gateway_startup.py | 14 +- backend/tests/test_serve_nginx_stop.py | 16 +- backend/tests/test_serve_pick_python.py | 7 +- backend/tests/test_support_shell.py | 147 ++++++++++++++++++ 9 files changed, 381 insertions(+), 35 deletions(-) create mode 100644 backend/tests/support/shell.py create mode 100644 backend/tests/test_support_shell.py diff --git a/backend/tests/support/shell.py b/backend/tests/support/shell.py new file mode 100644 index 000000000..04390d39f --- /dev/null +++ b/backend/tests/support/shell.py @@ -0,0 +1,122 @@ +"""Locate a shell that can actually run the repo's POSIX shell scripts. + +On POSIX hosts ``bash``/``sh`` from PATH are fine. On Windows the suite must +run Git Bash (MSYS2): the WSL launcher at ``%SystemRoot%\\System32\\bash.exe`` +and the Microsoft Store alias stubs under ``WindowsApps`` also answer to the +name ``bash`` — and CreateProcess searches System32 before PATH, so even a +literal ``["bash", ...]`` argv with Git Bash first on PATH still reaches +WSL — but neither can run repo scripts against Windows checkout paths. The +discovery below mirrors what ``scripts/run-with-git-bash.cmd`` already does +for the Makefile. +""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +import pytest + + +def _script_bash_candidates(git: str | None, program_files: str | None, path_bash: str | None) -> list[Path]: + """Build the ordered bash candidate list for Windows hosts.""" + candidates: list[Path] = [] + if git is not None: + # Git for Windows layout: /cmd/git.exe (or /bin/git.exe) + # both resolve to /bin/bash.exe two levels up from git's parent. + candidates.append(Path(git).resolve().parent.parent / "bin" / "bash.exe") + if program_files: + candidates.append(Path(program_files) / "Git" / "bin" / "bash.exe") + if path_bash is not None: + candidates.append(Path(path_bash)) + return candidates + + +def _script_sh_candidates(git: str | None, program_files: str | None) -> list[Path]: + """Build the ordered POSIX-sh candidate list for Windows hosts. + + Git for Windows ships a real ``sh.exe`` at ``/bin/sh.exe`` (and + ``/usr/bin/sh.exe``); preferring it over bash lets ``#!/bin/sh`` + scripts run under an actual sh, like the POSIX CI legs. + """ + candidates: list[Path] = [] + if git is not None: + git_root = Path(git).resolve().parent.parent + candidates.append(git_root / "bin" / "sh.exe") + candidates.append(git_root / "usr" / "bin" / "sh.exe") + if program_files: + program_files_git = Path(program_files) / "Git" + candidates.append(program_files_git / "bin" / "sh.exe") + candidates.append(program_files_git / "usr" / "bin" / "sh.exe") + return candidates + + +def _first_runnable_shell(candidates: list[Path], system_root: Path) -> str | None: + """Return the first candidate that is a real shell, rejecting stub launchers. + + The WSL launcher lives in System32/SysWOW64 and the Microsoft Store alias + stubs live under WindowsApps; neither is an MSYS2 shell that can run repo + scripts against Windows checkout paths. + """ + rejected_parents = (system_root / "System32", system_root / "SysWOW64") + for candidate in candidates: + try: + resolved = candidate.resolve() + except OSError: + continue + if not resolved.is_file(): + continue + if any(parent in resolved.parents for parent in rejected_parents): + continue + if "WindowsApps" in resolved.parts: + continue + return str(resolved) + return None + + +def find_script_bash() -> str | None: + """Return a bash able to run the repo's shell scripts, or ``None``.""" + if os.name != "nt": + return shutil.which("bash") + + candidates = _script_bash_candidates( + git=shutil.which("git"), + program_files=os.environ.get("ProgramFiles"), + path_bash=shutil.which("bash"), + ) + system_root = Path(os.environ.get("SystemRoot", r"C:\Windows")) + return _first_runnable_shell(candidates, system_root) + + +def require_script_bash() -> str: + """Return :func:`find_script_bash`'s result, skipping the test when absent.""" + bash = find_script_bash() + if bash is None: + pytest.skip("repo shell-script tests need Git Bash on Windows") + return bash + + +def find_posix_sh() -> str | None: + """Return a shell able to run the repo's POSIX-sh scripts. + + Plain ``sh`` on POSIX hosts. On Windows no ``sh`` exists on PATH outside + an MSYS2 installation, so prefer the real ``sh.exe`` shipped with Git for + Windows and fall back to Git Bash when it is absent. + """ + if os.name != "nt": + return shutil.which("sh") + candidates = _script_sh_candidates( + git=shutil.which("git"), + program_files=os.environ.get("ProgramFiles"), + ) + system_root = Path(os.environ.get("SystemRoot", r"C:\Windows")) + return _first_runnable_shell(candidates, system_root) or find_script_bash() + + +def require_posix_sh() -> str: + """Return :func:`find_posix_sh`'s result, skipping the test when absent.""" + sh = find_posix_sh() + if sh is None: + pytest.skip("repo shell-script tests need Git Bash on Windows") + return sh diff --git a/backend/tests/test_config_version.py b/backend/tests/test_config_version.py index d5d560f64..7c4f734f5 100644 --- a/backend/tests/test_config_version.py +++ b/backend/tests/test_config_version.py @@ -7,10 +7,16 @@ import os import tempfile from pathlib import Path +import pytest import yaml +from support.shell import find_script_bash from deerflow.config.app_config import AppConfig +# Only the upgrade-script test shells out; it needs Git Bash on Windows (the +# WSL launcher and Store alias stubs cannot run the repo scripts). +SCRIPT_BASH = find_script_bash() + def _make_config_files(tmpdir: Path, user_config: dict, example_config: dict) -> Path: """Write user config.yaml and config.example.yaml to a temp dir, return config path.""" @@ -126,6 +132,7 @@ def test_newer_user_version_no_warning(caplog): assert "outdated" not in caplog.text +@pytest.mark.skipif(SCRIPT_BASH is None, reason="repo shell-script tests need Git Bash on Windows") def test_version_26_config_upgrades_to_checkpoint_channel_mode(tmp_path, caplog): """A v26 user config must be flagged outdated and merge the new persisted field. @@ -158,7 +165,7 @@ def test_version_26_config_upgrades_to_checkpoint_channel_mode(tmp_path, caplog) env = {**os.environ, "DEER_FLOW_CONFIG_PATH": str(config_path)} result = subprocess.run( - ["bash", str(repo_root / "scripts" / "config-upgrade.sh")], + [SCRIPT_BASH, str(repo_root / "scripts" / "config-upgrade.sh")], env=env, capture_output=True, text=True, diff --git a/backend/tests/test_deploy_uv_extras.py b/backend/tests/test_deploy_uv_extras.py index 7ac6c36ce..80ce835cd 100644 --- a/backend/tests/test_deploy_uv_extras.py +++ b/backend/tests/test_deploy_uv_extras.py @@ -10,8 +10,16 @@ import subprocess import sys from pathlib import Path +import pytest +from support.shell import find_script_bash + REPO_ROOT = Path(__file__).resolve().parents[2] +# Every test here shells out to sh/bash; on Windows that must be Git Bash — +# the WSL launcher and Store alias stubs cannot run the repo scripts. +BASH = find_script_bash() +pytestmark = pytest.mark.skipif(BASH is None, reason="repo shell-script tests need Git Bash on Windows") + def _backend_dockerfile_uv_sync_script() -> str: dockerfile = (REPO_ROOT / "backend" / "Dockerfile").read_text(encoding="utf-8") @@ -41,7 +49,7 @@ def test_backend_dockerfile_expands_multiple_uv_extras(tmp_path): env["UV_EXTRAS"] = "discord,postgres" subprocess.run( - ["sh", "-c", _backend_dockerfile_uv_sync_script()], + [BASH, "-c", _backend_dockerfile_uv_sync_script()], cwd=workdir, env=env, check=True, @@ -81,7 +89,7 @@ def test_backend_dockerfile_rejects_glob_uv_extra(tmp_path): env["UV_EXTRAS"] = "postgres,*" result = subprocess.run( - ["sh", "-c", _backend_dockerfile_uv_sync_script()], + [BASH, "-c", _backend_dockerfile_uv_sync_script()], cwd=workdir, env=env, check=False, @@ -121,7 +129,7 @@ def test_deploy_build_auto_detects_postgres_extra_when_other_extras_are_enabled( env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" subprocess.run( - ["bash", str(worktree / "scripts" / "deploy.sh"), "build"], + [BASH, str(worktree / "scripts" / "deploy.sh"), "build"], cwd=worktree, env=env, check=True, @@ -167,7 +175,7 @@ def test_deploy_uses_dotenv_without_sourcing_shell_syntax(tmp_path): env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" subprocess.run( - ["bash", str(worktree / "scripts" / "deploy.sh"), "build"], + [BASH, str(worktree / "scripts" / "deploy.sh"), "build"], cwd=worktree, env=env, check=True, @@ -179,7 +187,18 @@ def test_deploy_uses_dotenv_without_sourcing_shell_syntax(tmp_path): assert capture_extras.read_text(encoding="utf-8") == "discord" args = capture_args.read_text(encoding="utf-8").splitlines() assert "--env-file" in args - assert str(worktree / ".env") in args + env_file_arg = args[args.index("--env-file") + 1] + # The flag must reference the worktree's .env, but its spelling depends on + # the shell that produced it: Git Bash renders host paths in MSYS form + # (/tmp/... for %TEMP%, /c/... otherwise). Resolve through that same bash + # instead of comparing against this platform's literal path string. + assert env_file_arg.replace("\\", "/").endswith("/.env") + probe = subprocess.run( + [BASH, "-c", 'test -f "$1" && cmp -s "$1" "$2"', "--", env_file_arg, str(worktree / ".env")], + check=False, + capture_output=True, + ) + assert probe.returncode == 0, f"--env-file target {env_file_arg!r} is not the worktree .env" def test_deploy_build_auto_detects_postgres_extra_with_python_fallback(tmp_path): @@ -219,7 +238,7 @@ def test_deploy_build_auto_detects_postgres_extra_with_python_fallback(tmp_path) env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" subprocess.run( - ["bash", str(worktree / "scripts" / "deploy.sh"), "build"], + [BASH, str(worktree / "scripts" / "deploy.sh"), "build"], cwd=worktree, env=env, check=True, diff --git a/backend/tests/test_dev_entrypoint.py b/backend/tests/test_dev_entrypoint.py index e14b1881c..0e42b0d8f 100644 --- a/backend/tests/test_dev_entrypoint.py +++ b/backend/tests/test_dev_entrypoint.py @@ -9,15 +9,58 @@ same shape — see PR #2767 / Issue #2754. from __future__ import annotations import os +import shlex +import shutil import subprocess +import sys +import tempfile +from collections.abc import Iterator from pathlib import Path import pytest +from support.shell import require_posix_sh REPO_ROOT = Path(__file__).resolve().parents[2] ENTRYPOINT = REPO_ROOT / "docker" / "dev-entrypoint.sh" +_PYTHON_SHIM_DIR: str | None = None + + +def _windows_python_shim_dir() -> str | None: + """Return a bin dir with working python3/python shims (Windows only). + + The Microsoft Store alias stubs answer `command -v python3` but exit 49 + when exec'd — the failure mode #5179 hardened serve.sh against — which + would send the entrypoint's detector probe to a broken interpreter. + """ + global _PYTHON_SHIM_DIR + if os.name != "nt": + return None + if _PYTHON_SHIM_DIR is None: + shim_dir = tempfile.mkdtemp(prefix="dev-entrypoint-python-shim-") + for name in ("python3", "python"): + shim = Path(shim_dir) / name + # newline="\n": the default newline=None would write CRLF on + # Windows, gluing a stray \r onto the shim's last argument. + shim.write_text( + f'#!/bin/sh\nexec {shlex.quote(sys.executable)} "$@"\n', + encoding="utf-8", + newline="\n", + ) + shim.chmod(0o755) + _PYTHON_SHIM_DIR = shim_dir + return _PYTHON_SHIM_DIR + + +@pytest.fixture(scope="module", autouse=True) +def _cleanup_python_shim_dir() -> Iterator[None]: + """Remove the session-scoped python shim directory after the module.""" + yield + if _PYTHON_SHIM_DIR is not None: + shutil.rmtree(_PYTHON_SHIM_DIR, ignore_errors=True) + + def _run( uv_extras: str | None, *, @@ -35,8 +78,12 @@ def _run( env["DEER_FLOW_CONFIG_PATH"] = str(config_path) if stream_bridge_redis_url is not None: env["DEER_FLOW_STREAM_BRIDGE_REDIS_URL"] = stream_bridge_redis_url + python_shim_dir = _windows_python_shim_dir() + if python_shim_dir is not None: + env["PATH"] = f"{python_shim_dir}{os.pathsep}{env['PATH']}" + sh = require_posix_sh() return subprocess.run( - ["sh", str(ENTRYPOINT), "--print-extras"], + [sh, str(ENTRYPOINT), "--print-extras"], cwd=ENTRYPOINT.parent, env=env, capture_output=True, @@ -48,7 +95,8 @@ def _run( def test_entrypoint_script_exists_and_is_posix_sh(): assert ENTRYPOINT.is_file() # Catch syntax errors before runtime — `sh -n` is a parse-only check. - proc = subprocess.run(["sh", "-n", str(ENTRYPOINT)], capture_output=True, text=True, check=False) + sh = require_posix_sh() + proc = subprocess.run([sh, "-n", str(ENTRYPOINT)], capture_output=True, text=True, check=False) assert proc.returncode == 0, proc.stderr @@ -218,7 +266,8 @@ def _run_sync_block(tmp_path: Path, stub_uv: str) -> subprocess.CompletedProcess bin_dir = tmp_path / "bin" bin_dir.mkdir() uv_stub = bin_dir / "uv" - uv_stub.write_text(stub_uv, encoding="utf-8") + # newline="\n": keep the stub POSIX-sh clean on Windows (no stray \r). + uv_stub.write_text(stub_uv, encoding="utf-8", newline="\n") uv_stub.chmod(0o755) state_dir = tmp_path / "state" @@ -231,7 +280,7 @@ def _run_sync_block(tmp_path: Path, stub_uv: str) -> subprocess.CompletedProcess env = os.environ.copy() env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" env["STUB_UV_STATE"] = str(state_dir) - return subprocess.run(["sh", "-c", script], capture_output=True, text=True, check=False, env=env, cwd=tmp_path) + return subprocess.run([require_posix_sh(), "-c", script], capture_output=True, text=True, check=False, env=env, cwd=tmp_path) def test_successful_sync_reaches_the_uvicorn_handoff(tmp_path: Path): diff --git a/backend/tests/test_docker_sandbox_mode_detection.py b/backend/tests/test_docker_sandbox_mode_detection.py index c7ad193c5..dc70f3b86 100644 --- a/backend/tests/test_docker_sandbox_mode_detection.py +++ b/backend/tests/test_docker_sandbox_mode_detection.py @@ -7,23 +7,16 @@ import shutil import subprocess import tempfile from pathlib import Path -from shutil import which import pytest +from support.shell import find_script_bash REPO_ROOT = Path(__file__).resolve().parents[2] SCRIPT_PATH = REPO_ROOT / "scripts" / "docker.sh" -BASH_CANDIDATES = [ - Path(r"C:\Program Files\Git\bin\bash.exe"), - Path(which("bash")) if which("bash") else None, -] -BASH_EXECUTABLE = next( - (str(path) for path in BASH_CANDIDATES if path is not None and path.exists() and "WindowsApps" not in str(path)), - None, -) +BASH_EXECUTABLE = find_script_bash() if BASH_EXECUTABLE is None: - pytestmark = pytest.mark.skip(reason="bash is required for docker.sh detection tests") + pytestmark = pytest.mark.skip(reason="Git Bash is required for docker.sh detection tests") def _detect_mode_with_config(config_content: str) -> str: diff --git a/backend/tests/test_gateway_startup.py b/backend/tests/test_gateway_startup.py index afdc7f8b7..63109e337 100644 --- a/backend/tests/test_gateway_startup.py +++ b/backend/tests/test_gateway_startup.py @@ -7,8 +7,16 @@ import shutil import subprocess from pathlib import Path +import pytest +from support.shell import find_script_bash + REPO_ROOT = Path(__file__).resolve().parents[2] +# Only the two deploy.sh tests shell out; they need Git Bash on Windows (the +# WSL launcher and Store alias stubs cannot run the repo scripts). +SCRIPT_BASH = find_script_bash() +requires_script_bash = pytest.mark.skipif(SCRIPT_BASH is None, reason="repo shell-script tests need Git Bash on Windows") + def _read(path: str) -> str: return (REPO_ROOT / path).read_text(encoding="utf-8") @@ -62,6 +70,7 @@ def test_production_gateway_has_a_real_readiness_probe() -> None: assert "gateway:\n condition: service_healthy" in compose +@requires_script_bash def test_deploy_waits_for_gateway_readiness_before_success(tmp_path: Path) -> None: capture = tmp_path / "docker-args.txt" worktree, env = _deploy_fixture( @@ -71,7 +80,7 @@ def test_deploy_waits_for_gateway_readiness_before_success(tmp_path: Path) -> No env["CAPTURE_DOCKER_ARGS"] = str(capture) result = subprocess.run( - ["bash", str(worktree / "scripts" / "deploy.sh"), "start"], + [SCRIPT_BASH, str(worktree / "scripts" / "deploy.sh"), "start"], cwd=worktree, env=env, check=False, @@ -86,6 +95,7 @@ def test_deploy_waits_for_gateway_readiness_before_success(tmp_path: Path) -> No assert "DeerFlow is running!" in result.stdout +@requires_script_bash def test_deploy_failure_prints_gateway_diagnostics_and_never_claims_success(tmp_path: Path) -> None: capture = tmp_path / "docker-calls.txt" worktree, env = _deploy_fixture( @@ -95,7 +105,7 @@ def test_deploy_failure_prints_gateway_diagnostics_and_never_claims_success(tmp_ env["CAPTURE_DOCKER_CALLS"] = str(capture) result = subprocess.run( - ["bash", str(worktree / "scripts" / "deploy.sh"), "start"], + [SCRIPT_BASH, str(worktree / "scripts" / "deploy.sh"), "start"], cwd=worktree, env=env, check=False, diff --git a/backend/tests/test_serve_nginx_stop.py b/backend/tests/test_serve_nginx_stop.py index b2a6c317e..b6440e8d1 100644 --- a/backend/tests/test_serve_nginx_stop.py +++ b/backend/tests/test_serve_nginx_stop.py @@ -3,11 +3,11 @@ from __future__ import annotations import shlex -import shutil import subprocess from pathlib import Path import pytest +from support.shell import require_script_bash REPO_ROOT = Path(__file__).resolve().parents[2] SERVE_SH = REPO_ROOT / "scripts" / "serve.sh" @@ -33,12 +33,10 @@ def _is_repo_nginx_pid( *, command: str, args: str, - repo_root: Path, + repo_root: Path | str, deerflow_pid: bool = False, ) -> bool: - bash = shutil.which("bash") - if bash is None: - pytest.skip("bash is required to exercise serve.sh helpers") + bash = require_script_bash() function = _extract_shell_function("_is_repo_nginx_pid") script = f""" @@ -69,8 +67,12 @@ _is_repo_nginx_pid 12345 def test_repo_nginx_pid_accepts_macos_rewritten_master_command(tmp_path): - repo_root = tmp_path / "deer-flow" - nginx_conf = repo_root / "docker" / "nginx" / "nginx.local.conf" + # The simulated macOS ps line embeds POSIX-form paths no matter which host + # runs this test, so build the fixture with forward slashes explicitly; + # on Windows a Path would render with backslashes and never match the + # "$root"/docker/nginx/... pattern the shell function greps for. + repo_root = (tmp_path / "deer-flow").as_posix() + nginx_conf = f"{repo_root}/docker/nginx/nginx.local.conf" assert _is_repo_nginx_pid( command=f"nginx: master process /opt/homebrew/bin/nginx -c {nginx_conf}", diff --git a/backend/tests/test_serve_pick_python.py b/backend/tests/test_serve_pick_python.py index ee89f5372..c9120c827 100644 --- a/backend/tests/test_serve_pick_python.py +++ b/backend/tests/test_serve_pick_python.py @@ -5,11 +5,10 @@ python alias stubs pass Bash's own PATH lookup but cannot be exec'd through from __future__ import annotations import shlex -import shutil import subprocess from pathlib import Path -import pytest +from support.shell import require_script_bash REPO_ROOT = Path(__file__).resolve().parents[2] SERVE_SH = REPO_ROOT / "scripts" / "serve.sh" @@ -69,9 +68,7 @@ def _to_bash_path(path: Path) -> str: def _run_pick_python(tmp_path: Path, *, env_mock: str = "") -> subprocess.CompletedProcess: - bash = shutil.which("bash") - if bash is None: - pytest.skip("bash is required to exercise serve.sh helpers") + bash = require_script_bash() script = _SCRIPT_TEMPLATE.replace("__BIN__", shlex.quote(_to_bash_path(tmp_path / "bin"))).replace("__STUBS__", "python3 python py").replace("__ENV_MOCK__", env_mock).replace("__FUNCTION__", _extract_shell_function("_pick_python")) # errors="replace": bash's diagnostics may arrive in the console's code diff --git a/backend/tests/test_support_shell.py b/backend/tests/test_support_shell.py new file mode 100644 index 000000000..d8258475c --- /dev/null +++ b/backend/tests/test_support_shell.py @@ -0,0 +1,147 @@ +"""Unit tests for the Windows shell discovery in tests/support/shell.py. + +``find_script_bash``/``find_posix_sh`` return at the ``os.name != "nt"`` +early-out on POSIX CI, so the candidate-rejection rules would otherwise only +ever run on contributors' Windows machines -- exactly the failure mode the +discovery exists to fix. The candidate construction and the rejection scan are +pinned here against real files (``tmp_path``) with faked inputs, so the logic +is exercised on every CI leg. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from support import shell + + +def _touch(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("", encoding="utf-8") + return path + + +@pytest.fixture() +def fake_windows(tmp_path: Path) -> Path: + """A fake Windows layout: git root, ProgramFiles Git, WSL + Store stubs.""" + _touch(tmp_path / "git" / "cmd" / "git.exe") + _touch(tmp_path / "git" / "bin" / "bash.exe") + _touch(tmp_path / "git" / "bin" / "sh.exe") + _touch(tmp_path / "git" / "usr" / "bin" / "sh.exe") + _touch(tmp_path / "ProgramFiles" / "Git" / "bin" / "bash.exe") + _touch(tmp_path / "ProgramFiles" / "Git" / "usr" / "bin" / "sh.exe") + _touch(tmp_path / "Windows" / "System32" / "bash.exe") # WSL launcher + _touch(tmp_path / "Windows" / "SysWOW64" / "bash.exe") # 32-bit WSL launcher + _touch(tmp_path / "WindowsApps" / "bash.exe") # Microsoft Store alias stub + return tmp_path + + +def _system_root(root: Path) -> Path: + return root / "Windows" + + +def test_system32_bash_rejected_even_when_only_path_hit(fake_windows: Path): + wsl_launcher = fake_windows / "Windows" / "System32" / "bash.exe" + + result = shell._first_runnable_shell([wsl_launcher], _system_root(fake_windows)) + + assert result is None + + +def test_syswow64_bash_rejected(fake_windows: Path): + syswow64 = fake_windows / "Windows" / "SysWOW64" / "bash.exe" + + result = shell._first_runnable_shell([syswow64], _system_root(fake_windows)) + + assert result is None + + +def test_windowsapps_store_stub_rejected(fake_windows: Path): + store_stub = fake_windows / "WindowsApps" / "bash.exe" + + result = shell._first_runnable_shell([store_stub], _system_root(fake_windows)) + + assert result is None + + +def test_rejected_candidates_fall_through_to_real_shell(fake_windows: Path): + wsl_launcher = fake_windows / "Windows" / "System32" / "bash.exe" + git_bash = fake_windows / "git" / "bin" / "bash.exe" + + result = shell._first_runnable_shell([wsl_launcher, git_bash], _system_root(fake_windows)) + + assert result == str(git_bash.resolve()) + + +def test_missing_candidates_are_skipped(fake_windows: Path): + missing = fake_windows / "no-such-dir" / "bash.exe" + git_bash = fake_windows / "git" / "bin" / "bash.exe" + + result = shell._first_runnable_shell([missing, git_bash], _system_root(fake_windows)) + + assert result == str(git_bash.resolve()) + + +def test_no_runnable_candidate_returns_none(fake_windows: Path): + missing = fake_windows / "no-such-dir" / "bash.exe" + wsl_launcher = fake_windows / "Windows" / "System32" / "bash.exe" + + result = shell._first_runnable_shell([missing, wsl_launcher], _system_root(fake_windows)) + + assert result is None + + +def test_git_derived_bash_candidate_comes_first(fake_windows: Path): + git_exe = str(fake_windows / "git" / "cmd" / "git.exe") + path_bash = str(fake_windows / "elsewhere" / "bash.exe") + + candidates = shell._script_bash_candidates(git=git_exe, program_files=None, path_bash=path_bash) + + git_root = (fake_windows / "git" / "cmd" / "git.exe").resolve().parent.parent + assert candidates == [git_root / "bin" / "bash.exe", Path(path_bash)] + + +def test_program_files_bash_used_when_git_absent(fake_windows: Path): + program_files = fake_windows / "ProgramFiles" + + candidates = shell._script_bash_candidates(git=None, program_files=str(program_files), path_bash=None) + + assert candidates == [program_files / "Git" / "bin" / "bash.exe"] + + +def test_posix_sh_prefers_git_sh_exe(fake_windows: Path): + git_exe = str(fake_windows / "git" / "cmd" / "git.exe") + + candidates = shell._script_sh_candidates(git=git_exe, program_files=None) + + git_root = (fake_windows / "git" / "cmd" / "git.exe").resolve().parent.parent + assert candidates == [ + git_root / "bin" / "sh.exe", + git_root / "usr" / "bin" / "sh.exe", + ] + + +def test_posix_sh_falls_back_to_program_files(fake_windows: Path): + program_files = fake_windows / "ProgramFiles" + + candidates = shell._script_sh_candidates(git=None, program_files=str(program_files)) + + assert candidates == [ + program_files / "Git" / "bin" / "sh.exe", + program_files / "Git" / "usr" / "bin" / "sh.exe", + ] + + +@pytest.mark.skipif(os.name != "nt", reason="real Windows environment reads") +def test_find_script_bash_never_returns_wsl_or_store_stubs(): + """On a real Windows host the result must not be a stub launcher.""" + result = shell.find_script_bash() + + if result is None: + pytest.skip("no Git Bash installed on this machine") + assert "WindowsApps" not in Path(result).parts + system_root = Path(os.environ.get("SystemRoot", r"C:\Windows")) + assert system_root / "System32" not in Path(result).parents + assert system_root / "SysWOW64" not in Path(result).parents