From 8efd7b3df6e40c02d37c13ea33aa6667872e40c8 Mon Sep 17 00:00:00 2001 From: mechramc Date: Tue, 2 Jun 2026 06:45:48 -0500 Subject: [PATCH] Windows: improve Git Bash detection --- core/platform_compat.py | 42 ++++++++++++++++++++++++++++++----- launch-windows.ps1 | 22 +++++++++++++++++- tests/test_platform_compat.py | 37 ++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 tests/test_platform_compat.py diff --git a/core/platform_compat.py b/core/platform_compat.py index 01ebe32..f971244 100644 --- a/core/platform_compat.py +++ b/core/platform_compat.py @@ -14,6 +14,7 @@ Design rules: from __future__ import annotations import os +import ntpath import shutil import subprocess from pathlib import Path @@ -134,11 +135,40 @@ _BASH_CACHE: Optional[str] = None _BASH_PROBED = False # Common Git-for-Windows install locations to probe when bash isn't on PATH. -_WINDOWS_BASH_FALLBACKS = ( - r"C:\Program Files\Git\bin\bash.exe", - r"C:\Program Files\Git\usr\bin\bash.exe", - r"C:\Program Files (x86)\Git\bin\bash.exe", +_WINDOWS_BASH_ROOT_ENV_VARS = ( + "ProgramFiles", + "ProgramW6432", + "ProgramFiles(x86)", + "LocalAppData", ) +_WINDOWS_BASH_DEFAULT_ROOTS = ( + r"C:\Program Files\Git", + r"C:\Program Files (x86)\Git", +) +_WINDOWS_BASH_RELATIVE_PATHS = ( + ("bin", "bash.exe"), + ("usr", "bin", "bash.exe"), +) + + +def _windows_bash_fallbacks() -> List[str]: + roots: List[str] = [] + for env_name in _WINDOWS_BASH_ROOT_ENV_VARS: + base = os.environ.get(env_name) + if base: + roots.append(ntpath.join(base, "Git")) + roots.extend(_WINDOWS_BASH_DEFAULT_ROOTS) + + paths: List[str] = [] + seen = set() + for root in roots: + for rel in _WINDOWS_BASH_RELATIVE_PATHS: + path = ntpath.join(root, *rel) + key = path.lower() + if key not in seen: + seen.add(key) + paths.append(path) + return paths def find_bash() -> Optional[str]: @@ -153,9 +183,9 @@ def find_bash() -> Optional[str]: if _BASH_PROBED: return _BASH_CACHE _BASH_PROBED = True - found = shutil.which("bash") + found = which_tool("bash") if not found and IS_WINDOWS: - for cand in _WINDOWS_BASH_FALLBACKS: + for cand in _windows_bash_fallbacks(): if os.path.exists(cand): found = cand break diff --git a/launch-windows.ps1 b/launch-windows.ps1 index 42686f7..88ede8d 100644 --- a/launch-windows.ps1 +++ b/launch-windows.ps1 @@ -30,6 +30,26 @@ function Fail($msg) { exit 1 } +function Find-GitBash { + $cmd = Get-Command bash -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + + $roots = @() + foreach ($name in @("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)", "LocalAppData")) { + $base = [Environment]::GetEnvironmentVariable($name) + if ($base) { $roots += (Join-Path $base "Git") } + } + $roots += @("C:\Program Files\Git", "C:\Program Files (x86)\Git") + + foreach ($root in ($roots | Select-Object -Unique)) { + foreach ($relative in @("bin\bash.exe", "usr\bin\bash.exe")) { + $candidate = Join-Path $root $relative + if (Test-Path $candidate) { return $candidate } + } + } + return $null +} + # 1. Locate a Python interpreter (3.11+ required) Write-Step "Checking for Python" function Get-PythonVersionText($launcher, $launcherArgs) { @@ -101,7 +121,7 @@ Write-Step "Running first-time setup" if ($LASTEXITCODE -ne 0) { Fail "setup.py failed." } # 5. Friendly note about Git Bash (full Cookbook / agent-shell parity) -if (-not (Get-Command bash -ErrorAction SilentlyContinue)) { +if (-not (Find-GitBash)) { Write-Host "" Write-Host "NOTE: Git Bash (bash.exe) was not found on PATH." -ForegroundColor Yellow Write-Host " The core app works without it. For full Cookbook background" -ForegroundColor Yellow diff --git a/tests/test_platform_compat.py b/tests/test_platform_compat.py new file mode 100644 index 0000000..255974e --- /dev/null +++ b/tests/test_platform_compat.py @@ -0,0 +1,37 @@ +"""Regression tests for cross-platform helper behavior.""" + +from core import platform_compat + + +def _reset_bash_cache(monkeypatch): + monkeypatch.setattr(platform_compat, "_BASH_CACHE", None) + monkeypatch.setattr(platform_compat, "_BASH_PROBED", False) + + +def test_find_bash_tries_windows_exe_suffix(monkeypatch): + _reset_bash_cache(monkeypatch) + monkeypatch.setattr(platform_compat, "IS_WINDOWS", True) + + expected = r"C:\Program Files\Git\bin\bash.exe" + + def fake_which(name): + return expected if name == "bash.exe" else None + + monkeypatch.setattr(platform_compat.shutil, "which", fake_which) + monkeypatch.setattr(platform_compat.os.path, "exists", lambda _path: False) + + assert platform_compat.find_bash() == expected + + +def test_find_bash_checks_local_app_data_git_install(monkeypatch): + _reset_bash_cache(monkeypatch) + monkeypatch.setattr(platform_compat, "IS_WINDOWS", True) + monkeypatch.setattr(platform_compat.shutil, "which", lambda _name: None) + for env_name in platform_compat._WINDOWS_BASH_ROOT_ENV_VARS: + monkeypatch.delenv(env_name, raising=False) + monkeypatch.setenv("LocalAppData", r"C:\Users\alice\AppData\Local") + + expected = r"C:\Users\alice\AppData\Local\Git\bin\bash.exe" + monkeypatch.setattr(platform_compat.os.path, "exists", lambda path: path == expected) + + assert platform_compat.find_bash() == expected