fix(windows): put Git Bash coreutils on PATH for the non-login fallback

#63955 made Hermes survive a broken `bash -l` (Ainz's `Directory
\drivers\etc does not exist`) by falling back to non-login `bash -c`.
But a non-login shell never sources /etc/profile, so it never gets
`…\usr\bin` on PATH — and that dir holds every coreutil the file/terminal
tools shell out to (cat, mktemp, mv, wc, head, stat, chmod, mkdir, find).
Result: `write_file` returned bytes_written:0 with an EMPTY error (the
failure text went to a missing binary's stderr) and terminal commands
exited 127. The survive-broken-login-bash fix was only half-done: it
stopped crashing but silently failed every write.

Derive Git Bash's bin dirs (mingw64/bin, usr/bin, bin, …) from the
resolved bash.exe and prepend them to the subprocess PATH on Windows, in
/etc/profile precedence order so coreutils win over same-named System32
tools (find.exe, sort.exe) inside the shell. No-op off Windows and when a
login snapshot is healthy (the snapshot re-exports the full PATH inside
the shell), so this only bites on the broken-login fallback path.

Adds _git_bash_bin_dirs() (derivation, cached) + _prepend_git_bash_dirs()
(PATH merge), plus regression tests for PortableGit/MinGit layouts and
the run-env injection ordering.
This commit is contained in:
Brooklyn Nicholson 2026-07-13 20:00:32 -04:00
parent 2ccfdb2db4
commit 4a58e22e99
2 changed files with 168 additions and 0 deletions

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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) # <root>\bin or <root>\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.).