diff --git a/tests/agents/hooks/test_cleanup_worktrees.py b/tests/agents/hooks/test_cleanup_worktrees.py index 2c872ac8..929408f2 100644 --- a/tests/agents/hooks/test_cleanup_worktrees.py +++ b/tests/agents/hooks/test_cleanup_worktrees.py @@ -3,13 +3,13 @@ import json from pathlib import Path import shlex -import shutil import subprocess import sys import pytest from tests.agents.hooks.cleanup_worktrees_runtime import SCRIPT, branches, cleanup, git, task_worktree +from tests.runtime import real_git import herdr_ownership import session_store @@ -26,8 +26,7 @@ def test_a_merged_clean_worktree_and_its_branch_are_removed(primary: Path, merge def test_removal_does_not_repeat_gits_cleanliness_check(primary: Path, merged: dict[str, str], tmp_path: Path) -> None: path = task_worktree(primary, "feat/done") merged["feat/done"] = git("rev-parse", "HEAD", cwd=path) - executable = shutil.which("git") - assert executable + executable = real_git() wrapper = tmp_path / "bin/git" wrapper.write_text( '#!/bin/sh\ncase " $* " in *" worktree remove "*)\n' diff --git a/tests/agents/hooks/test_cleanup_worktrees_isolation.py b/tests/agents/hooks/test_cleanup_worktrees_isolation.py index ef88d24d..2f2d479e 100644 --- a/tests/agents/hooks/test_cleanup_worktrees_isolation.py +++ b/tests/agents/hooks/test_cleanup_worktrees_isolation.py @@ -9,6 +9,7 @@ import pytest_mock from tests.agents.hooks.cleanup_worktrees_runtime import branches, cleanup, git, task_worktree +from tests.runtime import real_git import cleanup_worktrees @@ -37,8 +38,7 @@ def test_lane_status_never_runs_a_filter_from_raced_admin_configuration( readme.write_text("base\n") index_time = (admin / "index").stat().st_mtime_ns os.utime(readme, ns=(index_time, index_time)) - executable = shutil.which("git") - assert executable + executable = real_git() rewrite = "" if planted in {"commondir", "both"}: rewrite += f"printf '%s' {shlex.quote(str(fake))} > {shlex.quote(str(admin / 'commondir'))}\n" @@ -241,12 +241,10 @@ def test_lane_fsmonitor_is_never_executed_and_git_and_gh_run_from_the_primary( git("config", *(["--worktree"] if config == "worktree" else []), "core.fsmonitor", str(monitor), cwd=path) discovery = tmp_path / "lane-discovery" for name in ("git", "gh"): - executable = shutil.which(name) - assert executable + executable = real_git() if name == "git" else str(tmp_path / "original-gh") wrapper = tmp_path / "bin" / name if name == "gh": wrapper.rename(tmp_path / "original-gh") - executable = str(tmp_path / "original-gh") wrapper.write_text( f'#!/bin/sh\ncase "$PWD" in {shlex.quote(str(path))}*|{shlex.quote(str(current))}*) ' f'touch {shlex.quote(str(discovery))}; exit 99;; esac\nexec {shlex.quote(executable)} "$@"\n' @@ -298,8 +296,7 @@ def test_a_planted_git_entry_inside_a_lane_is_neither_current_nor_primary( git("init", "--quiet", cwd=nested) monkeypatch.setenv("HOME", str(home)) marker = tmp_path / "nested-git-ran" - executable = shutil.which("git") - assert executable + executable = real_git() wrapper = tmp_path / "bin/git" wrapper.write_text( f'#!/bin/sh\nif [ "$PWD" = {shlex.quote(str(nested))} ]; then ' diff --git a/tests/agents/hooks/test_cleanup_worktrees_trust.py b/tests/agents/hooks/test_cleanup_worktrees_trust.py index e7260c01..3f981c71 100644 --- a/tests/agents/hooks/test_cleanup_worktrees_trust.py +++ b/tests/agents/hooks/test_cleanup_worktrees_trust.py @@ -4,7 +4,6 @@ import os from pathlib import Path import shlex -import shutil import tempfile from collections.abc import Iterator @@ -17,6 +16,7 @@ git, task_worktree, ) +from tests.runtime import real_git import cleanup_worktrees @@ -55,13 +55,11 @@ def herdr_lane(primary: Path) -> Path: def record_commands(tmp_path: Path, forbidden: Path) -> Path: marker = tmp_path / "untrusted-command" for name in ("git", "gh"): - executable = shutil.which(name) - assert executable + executable = real_git() if name == "git" else str(tmp_path / "original-gh") wrapper = tmp_path / "bin" / name if name == "gh": original = tmp_path / "original-gh" wrapper.rename(original) - executable = str(original) wrapper.write_text( f'#!/bin/sh\nprintf "%s %s %s\\n" "$PWD" {name} "$*" >> {shlex.quote(str(tmp_path / "commands"))}\n' f'case "$PWD $*" in *{shlex.quote(str(forbidden))}*) ' @@ -210,8 +208,7 @@ def test_git_removal_is_isolated_and_herdr_only_closes_the_workspace( lane = task_worktree(primary, "feat/done") worktree = next(tree for tree in cleanup_worktrees.linked_worktrees(primary) if tree.path == lane) # The process wrapper rejects any invocation missing the required isolation. - executable = shutil.which("git") - assert executable + executable = real_git() wrapper = tmp_path / "bin/git" wrapper.write_text( '#!/bin/sh\ncase " $* " in *" worktree remove "*)\n' diff --git a/tests/runtime.py b/tests/runtime.py index 8275c5ea..aa70c8d4 100644 --- a/tests/runtime.py +++ b/tests/runtime.py @@ -13,6 +13,15 @@ StubWriter = Callable[[str, str], Path] +def real_git() -> str: + """Resolve Git before installing a PATH wrapper that delegates to it.""" + result = subprocess.run(["git", "--exec-path"], check=True, capture_output=True, text=True, timeout=10) + # /usr/bin/git on macOS redispatches through PATH; git-core/git is the actual binary. + executable = (Path(result.stdout.strip()) / "git").resolve() + assert executable.is_file() + return str(executable) + + def load_script_module(relative_path: str, module_name: str) -> ModuleType: """Load a trusted repository module for direct behavior tests.""" spec = util.spec_from_file_location(module_name, REPO_ROOT / relative_path) diff --git a/tests/test_git_wrapper.py b/tests/test_git_wrapper.py new file mode 100644 index 00000000..fd8a4dc6 --- /dev/null +++ b/tests/test_git_wrapper.py @@ -0,0 +1,41 @@ +"""Git wrappers delegate without re-entering PATH-based platform shims.""" + +import os +from pathlib import Path +import shlex +import subprocess + +import pytest + +from tests.runtime import real_git + + +@pytest.mark.parametrize("shim_first", [True, False]) +def test_git_wrapper_bypasses_path_lookup_in_platform_shims( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, shim_first: bool +) -> None: + exec_path = subprocess.run(["git", "--exec-path"], check=True, capture_output=True, text=True).stdout.strip() + binary = Path(exec_path) / "git" + expected = subprocess.run([str(binary), "--version"], check=True, capture_output=True, text=True).stdout + shim = tmp_path / "shim" + shim.mkdir() + (shim / "git").write_text( + '#!/bin/sh\nif [ "$1" = --exec-path ]; then\n' + f"printf '%s\\n' {shlex.quote(exec_path)}\n" + 'else\nexec git "$@"\nfi\n' + ) + (shim / "git").chmod(0o755) + directories = [str(shim), str(binary.parent)] + if not shim_first: + directories.reverse() + monkeypatch.setenv("PATH", os.pathsep.join(directories)) + executable = real_git() + wrapper = tmp_path / "wrapper" + wrapper.mkdir() + (wrapper / "git").write_text(f'#!/bin/sh\nexec {shlex.quote(executable)} "$@"\n') + (wrapper / "git").chmod(0o755) + monkeypatch.setenv("PATH", str(wrapper) + os.pathsep + os.environ["PATH"]) + + result = subprocess.run(["git", "--version"], check=True, capture_output=True, text=True, timeout=30) + + assert result.stdout == expected