diff --git a/tests/tools/test_local_env_windows_msys.py b/tests/tools/test_local_env_windows_msys.py index 938cd8783f18..f5eba3f252a8 100644 --- a/tests/tools/test_local_env_windows_msys.py +++ b/tests/tools/test_local_env_windows_msys.py @@ -18,6 +18,7 @@ and ``os.path.isdir`` so the MSYS path tests as "missing" exactly like on the real OS. """ +import os from unittest.mock import patch from tools.environments.base import BaseEnvironment @@ -25,8 +26,10 @@ from tools.environments import local as local_mod from tools.environments.local import ( LocalEnvironment, _bash_safe_path, + _git_bash_bin_dirs, _make_run_env, _msys_to_windows_path, + _prepend_git_bash_dirs, _quote_bash_path, _resolve_safe_cwd, _sanitize_subprocess_env, @@ -325,6 +328,88 @@ class TestWindowsMsysPathconvDefaults: assert run_env.get("MSYS2_ARG_CONV_EXCL") == "/custom" +# --------------------------------------------------------------------------- +# Git Bash coreutils on PATH — non-login ``bash -c`` fallback (empty +# write_file error / terminal exit 127 when login bash is broken) +# --------------------------------------------------------------------------- + +class TestGitBashCoreutilsOnPath: + def _fake_isdir(self, existing): + existing = {e.replace("\\", "/") for e in existing} + return lambda p: p.replace("\\", "/") in existing + + def test_derives_dirs_from_portablegit_layout(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + monkeypatch.setattr(local_mod, "_find_bash", lambda: "/pg/bin/bash.exe") + existing = {"/pg/mingw64/bin", "/pg/usr/bin", "/pg/bin"} + monkeypatch.setattr(local_mod.os.path, "isdir", self._fake_isdir(existing)) + + dirs = _git_bash_bin_dirs() + + # usr/bin is the load-bearing coreutils dir; mingw64 precedes it. + assert "/pg/usr/bin" in dirs + assert dirs.index("/pg/mingw64/bin") < dirs.index("/pg/usr/bin") + # Non-existent dirs (mingw32, usr/local/bin) are excluded. + assert "/pg/mingw32/bin" not in dirs + + def test_derives_dirs_from_mingit_usr_bin_layout(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + monkeypatch.setattr(local_mod, "_find_bash", lambda: "/mg/usr/bin/bash.exe") + existing = {"/mg/usr/bin", "/mg/mingw64/bin"} + monkeypatch.setattr(local_mod.os.path, "isdir", self._fake_isdir(existing)) + + dirs = _git_bash_bin_dirs() + + # MinGit ships bash under usr\bin; root must still resolve to /mg. + assert "/mg/usr/bin" in dirs + assert "/mg/mingw64/bin" in dirs + + def test_empty_off_windows(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", False) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + assert _git_bash_bin_dirs() == [] + + def test_empty_when_bash_unresolvable(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + + def boom(): + raise RuntimeError("Git Bash not found") + + monkeypatch.setattr(local_mod, "_find_bash", boom) + assert _git_bash_bin_dirs() == [] + + def test_prepend_is_idempotent(self, monkeypatch): + # Simulate Windows' ``;`` separator so drive-letter colons in fake + # paths don't collide with the POSIX ``:`` pathsep on the test host. + monkeypatch.setattr(os, "pathsep", ";") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", ["/pg/usr/bin", "/pg/bin"]) + already = r"/pg/usr/bin;C:\Windows\System32;/pg/bin" + assert _prepend_git_bash_dirs(already) == already + + def test_make_run_env_prepends_coreutils_on_windows(self, monkeypatch): + monkeypatch.setattr(os, "pathsep", ";") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", ["/pg/mingw64/bin", "/pg/usr/bin"]) + run_env = _make_run_env({"PATH": r"C:\Windows\System32"}) + path = run_env.get("PATH") or run_env.get("Path") + entries = path.split(";") + # Coreutils dirs land before System32 so bash resolves cat/find/sort + # to the GNU tools, not the same-named Windows executables. + assert "/pg/usr/bin" in entries + assert entries.index("/pg/usr/bin") < entries.index(r"C:\Windows\System32") + + def test_make_run_env_noop_on_posix(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", False) + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs_cache", None) + run_env = _make_run_env({"PATH": "/usr/bin:/bin"}) + # No Windows git dirs injected on POSIX. + assert "mingw64" not in run_env["PATH"] + + # --------------------------------------------------------------------------- # Command wrapping — native Windows cwd must be Git Bash-friendly for cd # --------------------------------------------------------------------------- diff --git a/tools/environments/local.py b/tools/environments/local.py index e5f6c6c2b262..0a867fa984bd 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -658,6 +658,82 @@ def _bash_starts(bash: str) -> bool: return ok +_git_bash_bin_dirs_cache: "list[str] | None" = None + + +def _git_bash_bin_dirs() -> list[str]: + """Git Bash's coreutils/binary dirs, in ``/etc/profile`` precedence order. + + A non-login ``bash -c`` (the fallback used when ``bash -l`` is broken — + the classic Windows ``Directory \\drivers\\etc does not exist`` failure) + never sources ``/etc/profile``, so it never gets ``…\\usr\\bin`` on PATH. + That directory holds every coreutil the file/terminal tools shell out to + (``cat``, ``mktemp``, ``mv``, ``wc``, ``head``, ``stat``, ``chmod``, + ``mkdir``, ``find`` …). Without it, ``write_file`` fails with an empty + error (the failure text went to a missing binary's stderr) and terminal + commands exit 127. We derive these dirs from the resolved ``bash.exe`` so + the fallback shell can find coreutils regardless of the login shell. + + Returns ``[]`` off Windows or when bash can't be located. Dirs are + returned in the order Git Bash's own ``/etc/profile`` prepends them + (mingw first, then usr/bin, then bin) and only if they exist on disk. + """ + global _git_bash_bin_dirs_cache + if _git_bash_bin_dirs_cache is not None: + return _git_bash_bin_dirs_cache + + if not _IS_WINDOWS: + _git_bash_bin_dirs_cache = [] + return _git_bash_bin_dirs_cache + + dirs: list[str] = [] + try: + bash = _find_bash() + except Exception: + _git_bash_bin_dirs_cache = [] + return _git_bash_bin_dirs_cache + + bin_dir = os.path.dirname(bash) # \bin or \usr\bin + parent = os.path.dirname(bin_dir) + # MinGit ships bash under usr\bin; PortableGit/system Git under bin. + root = os.path.dirname(parent) if os.path.basename(parent).lower() == "usr" else parent + + # Order mirrors Git-for-Windows /etc/profile so coreutils win over the + # same-named Windows System32 tools (find.exe, sort.exe) inside the shell. + for candidate in ( + os.path.join(root, "mingw64", "bin"), + os.path.join(root, "mingw32", "bin"), + os.path.join(root, "usr", "local", "bin"), + os.path.join(root, "usr", "bin"), + os.path.join(root, "bin"), + ): + if os.path.isdir(candidate) and candidate not in dirs: + dirs.append(candidate) + + _git_bash_bin_dirs_cache = dirs + return dirs + + +def _prepend_git_bash_dirs(existing_path: str) -> str: + """Prepend Git Bash's binary dirs to ``existing_path`` if missing. + + No-op off Windows or when the dirs can't be resolved. First-occurrence + wins, so a PATH that already lists a dir keeps its position. This is what + lets the non-login ``bash -c`` fallback find coreutils; in the healthy + case the session snapshot re-exports the full login PATH inside the shell, + so this only matters when that snapshot is absent. + """ + git_dirs = _git_bash_bin_dirs() + if not git_dirs: + return existing_path + sep = os.pathsep + entries = [e for e in existing_path.split(sep) if e] if existing_path else [] + missing = [d for d in git_dirs if d not in entries] + if not missing: + return existing_path + return sep.join([*missing, *entries]) + + # POSIX-sh-family shells that understand the ``[shell, "-lic", "set +m; …"]`` # invocation spawn_local uses. $SHELL values outside this set (fish, csh/tcsh, # nushell, elvish, xonsh, …) would error on that syntax, so _find_shell falls @@ -904,6 +980,13 @@ def _make_run_env(env: dict) -> dict: path_key = _path_env_key(run_env) if path_key is not None: new_path = _append_missing_sane_path_entries(run_env.get(path_key, "")) + # On Windows, ensure Git Bash's coreutils dirs (…\usr\bin etc.) are on + # PATH. A non-login ``bash -c`` fallback (used when ``bash -l`` is + # broken) never sources /etc/profile, so without this cat/mktemp/mv and + # friends are missing and every write_file/terminal call fails (empty + # error / exit 127). No-op off Windows and when a login snapshot is + # healthy (the snapshot re-exports the full PATH inside the shell). + new_path = _prepend_git_bash_dirs(new_path) # Ensure the hermes install dir is reachable so plugins can shell out # to bare ``hermes`` via the terminal tool even when the gateway was # launched without it on PATH (systemd, service managers, cron, etc.).