diff --git a/backend/tests/support/shell.py b/backend/tests/support/shell.py index 04390d39f1..017774f88f 100644 --- a/backend/tests/support/shell.py +++ b/backend/tests/support/shell.py @@ -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 + ``/cmd`` (or ``/bin``), so the first entry is the installation + root. PortableGit and GitHub Desktop keep it in ``/mingw64/bin``, where + only the second entry is; the first is ``/mingw64`` and typically holds + no ``bin/bash.exe`` in stock trees. Bash and sh themselves stay at + ``/bin`` and ``/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: /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") + 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") diff --git a/backend/tests/test_support_shell.py b/backend/tests/test_support_shell.py index d8258475cb..1a0122b9d2 100644 --- a/backend/tests/test_support_shell.py +++ b/backend/tests/test_support_shell.py @@ -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 (``/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.""" diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 89aa9a29ca..8b6c329820 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -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 `/bin` and `/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 diff --git a/scripts/run-with-git-bash.cmd b/scripts/run-with-git-bash.cmd index 8692ca3375..da96b74c84 100644 --- a/scripts/run-with-git-bash.cmd +++ b/scripts/run-with-git-bash.cmd @@ -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