fix(scripts): find Git Bash under a PortableGit mingw64 layout (#7214)

* fix(scripts): find Git Bash under a PortableGit mingw64 layout

`scripts/run-with-git-bash.cmd` derives `bash.exe` from `git.exe`'s parent,
which only holds for the Git for Windows `<root>/cmd` and `<root>/bin`
layouts. PortableGit and GitHub Desktop ship `git.exe` in
`<root>/mingw64/bin`, so the wrapper looked for `<root>/mingw64/bin/bash.exe`
and reported "Could not locate Git for Windows Bash" even though
`<root>/bin/bash.exe` exists. Every Makefile recipe that runs a repository
shell script failed on those hosts.

Resolve the shell under the Git installation root instead, and mirror the rule
in `backend/tests/support/shell.py` (documented as mirroring the wrapper) so
the shell-script suites stop skipping on the same hosts.

* test(backend): pin both Git layouts in one shell-discovery suite

Review follow-up for the PortableGit probe:

- `_git_roots` now probes `git.exe`'s parent and grandparent unconditionally —
  exactly the two directories the Makefile wrapper checks — so the batch probe
  and the Python mirror cannot drift; the docstring records that the first
  `mingw64` candidate normally misses and is kept only to mirror it.
- fold the candidate-layout cases and the Windows-only wrapper case into
  `tests/test_support_shell.py`, which already owns this module's unit tests,
  instead of a second file with a duplicate `_touch` helper.
- report the wrapper's stdout in the failure message: its diagnostic goes to
  stdout, so `stderr` was empty on the discovery-failure path.
This commit is contained in:
Aling33 2026-10-21 22:39:00 +08:00 committed by GitHub
parent 619132da5b
commit dede3faed4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 89 additions and 8 deletions

View File

@ -19,13 +19,26 @@ from pathlib import Path
import pytest
def _git_roots(git: str) -> list[Path]:
"""Return the two directories the Makefile wrapper probes for the Git root.
``scripts/run-with-git-bash.cmd`` checks ``bin\\bash.exe`` under ``git.exe``'s
directory and then one level above it. Git for Windows keeps ``git.exe`` in
``<root>/cmd`` (or ``<root>/bin``), so the first entry is the installation
root. PortableGit and GitHub Desktop keep it in ``<root>/mingw64/bin``, where
only the second entry is; the first is ``<root>/mingw64`` and typically holds
no ``bin/bash.exe`` in stock trees. Bash and sh themselves stay at
``<root>/bin`` and ``<root>/usr/bin`` in both layouts.
"""
git_dir = Path(git).resolve().parent
return [git_dir.parent, git_dir.parent.parent]
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: <root>/cmd/git.exe (or <root>/bin/git.exe)
# both resolve to <root>/bin/bash.exe two levels up from git's parent.
candidates.append(Path(git).resolve().parent.parent / "bin" / "bash.exe")
candidates.extend(root / "bin" / "bash.exe" for root in _git_roots(git))
if program_files:
candidates.append(Path(program_files) / "Git" / "bin" / "bash.exe")
if path_bash is not None:
@ -42,9 +55,9 @@ def _script_sh_candidates(git: str | None, program_files: str | None) -> list[Pa
"""
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")
for root in _git_roots(git):
candidates.append(root / "bin" / "sh.exe")
candidates.append(root / "usr" / "bin" / "sh.exe")
if program_files:
program_files_git = Path(program_files) / "Git"
candidates.append(program_files_git / "bin" / "sh.exe")

View File

@ -11,6 +11,8 @@ is exercised on every CI leg.
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
@ -100,7 +102,11 @@ def test_git_derived_bash_candidate_comes_first(fake_windows: Path):
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)]
assert candidates == [
git_root / "bin" / "bash.exe",
git_root.parent / "bin" / "bash.exe",
Path(path_bash),
]
def test_program_files_bash_used_when_git_absent(fake_windows: Path):
@ -120,6 +126,8 @@ def test_posix_sh_prefers_git_sh_exe(fake_windows: Path):
assert candidates == [
git_root / "bin" / "sh.exe",
git_root / "usr" / "bin" / "sh.exe",
git_root.parent / "bin" / "sh.exe",
git_root.parent / "usr" / "bin" / "sh.exe",
]
@ -134,6 +142,55 @@ def test_posix_sh_falls_back_to_program_files(fake_windows: Path):
]
def _portablegit_git(fake_windows: Path) -> tuple[Path, str]:
"""Create a PortableGit layout (``<root>/mingw64/bin/git.exe``) and return it."""
git_exe = _touch(fake_windows / "PortableGit" / "mingw64" / "bin" / "git.exe")
return (fake_windows / "PortableGit").resolve(), str(git_exe)
def test_portablegit_bash_candidate_reaches_the_git_root(fake_windows: Path):
git_root, git_exe = _portablegit_git(fake_windows)
candidates = shell._script_bash_candidates(git=git_exe, program_files=None, path_bash=None)
assert candidates == [git_root / "mingw64" / "bin" / "bash.exe", git_root / "bin" / "bash.exe"]
def test_portablegit_posix_sh_candidate_reaches_the_git_root(fake_windows: Path):
git_root, git_exe = _portablegit_git(fake_windows)
candidates = shell._script_sh_candidates(git=git_exe, program_files=None)
assert candidates == [
git_root / "mingw64" / "bin" / "sh.exe",
git_root / "mingw64" / "usr" / "bin" / "sh.exe",
git_root / "bin" / "sh.exe",
git_root / "usr" / "bin" / "sh.exe",
]
@pytest.mark.skipif(os.name != "nt", reason="the Makefile shell wrapper is a cmd.exe script")
def test_makefile_shell_wrapper_runs_portablegit_bash(tmp_path: Path):
"""`make` reaches Git Bash when `where git` reports the mingw64 layout.
A bare ``cmd.exe`` copy stands in for ``bash.exe``: the wrapper only has to
locate and launch it, and ``cmd /c echo`` keeps the assertion offline.
"""
git_root = tmp_path / "PortableGit"
_touch(git_root / "mingw64" / "bin" / "git.exe")
bash = git_root / "bin" / "bash.exe"
system_root = Path(os.environ.get("SystemRoot", r"C:\Windows"))
bash.parent.mkdir()
shutil.copy(system_root / "System32" / "cmd.exe", bash)
wrapper = Path(__file__).resolve().parents[2] / "scripts" / "run-with-git-bash.cmd"
env = {**os.environ, "PATH": os.pathsep.join([str(git_root / "mingw64" / "bin"), str(system_root / "System32")])}
result = subprocess.run(["cmd", "/c", str(wrapper), "/c", "echo", "BASH_OK"], capture_output=True, text=True, env=env, timeout=60)
assert result.returncode == 0, result.stdout + result.stderr
assert "BASH_OK" in result.stdout
@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."""

View File

@ -208,6 +208,13 @@ likewise prefix the target with `bash`. This keeps documented `make` commands
working when a source archive, `core.fileMode=false`, or a non-POSIX filesystem
does not preserve executable bits.
The Windows wrapper resolves `bash.exe` under the Git installation root, which
is the parent of `cmd/`, `bin/`, or `mingw64/bin/` depending on the distribution:
Git for Windows puts `git.exe` in `cmd/`, while PortableGit and GitHub Desktop
put it in `mingw64/bin/`. Bash and sh stay at `<root>/bin` and `<root>/usr/bin`
in both layouts. `backend/tests/support/shell.py` mirrors the same rule for the
shell-script suites.
`make clean` deletes `backend/.deer-flow` (database, users, threads, uploads,
secrets), which both compose stacks mount into `deer-flow-gateway`. Its recipe
runs `check-data-not-in-use.sh` before `make stop` (which would stop a live

View File

@ -8,9 +8,13 @@ for /f "delims=" %%I in ('where git 2^>NUL') do (
set "bash_exe=%%~dpI..\bin\bash.exe"
goto :found_bash
)
if exist "%%~dpI..\..\bin\bash.exe" (
set "bash_exe=%%~dpI..\..\bin\bash.exe"
goto :found_bash
)
)
echo Could not locate Git for Windows Bash ("..\bin\bash.exe" relative to git on PATH). Ensure Git for Windows is installed and that git and bash.exe are available on PATH.
echo Could not locate Git for Windows Bash ("bin\bash.exe" under the git root). Ensure Git for Windows is installed and that git and bash.exe are available on PATH.
exit /b 1
:found_bash