From 7131bcc27f2d96851eb6d667dc1207ab6ece5c61 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 30 Jul 2026 10:45:23 +0000 Subject: [PATCH] Resolve Windows hook Bash through PATH (#2198) Extensionless commit hooks on Windows were launched through a bare bash.exe. Windows CreateProcess searches system directories before PATH for bare executable names, so the WSL launcher in System32 could preempt Git for Windows Bash and make every commit fail when no WSL distribution was installed. A regression test mocks PATH resolution and demonstrates that the hook command previously remained bare instead of using the resolved executable. Resolve bash.exe with shutil.which before spawning, while retaining the bare-name fallback when PATH has no Bash so existing WSL-based behavior remains available. This matches Git for Windows v2.55.0.windows.3, whose git var GIT_SHELL_PATH reports the absolute PATH-selected sh.exe. It also follows gix-command's principle of resolving a Git-associated Windows shell before falling back to a bare executable name. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/index/fun.py | 21 ++++++++++++++++++++- test/test_index.py | 8 ++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/git/index/fun.py b/git/index/fun.py index 5d52486f9..7bd2e4bf6 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -79,6 +79,21 @@ def _has_file_extension(path: str) -> str: return osp.splitext(path)[1] +def _which_from_path(command: str) -> Union[str, None]: + """Resolve an executable 'command' from PATH without considering the current directory.""" + cwd = osp.normcase(osp.abspath(os.curdir)) + for directory in os.get_exec_path(): + if not directory: + continue + directory = osp.abspath(directory) + if osp.normcase(directory) == cwd: + continue + candidate = osp.join(directory, command) + if osp.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: """Run the commit hook of the given name. Silently ignore hooks that do not exist. @@ -112,7 +127,11 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: # an absolute path in this form, although a relative path is preferable # because it also works with the Windows Subsystem for Linux wrapper. bash_hp = hp - cmd = ["bash.exe", Path(bash_hp).as_posix()] + # Resolve through PATH before spawning. On Windows, CreateProcess searches + # the current and system directories before PATH for a bare executable name, + # which can otherwise select an impostor or the WSL launcher instead of Git + # for Windows' Bash. + cmd = [_which_from_path("bash.exe") or "bash.exe", Path(bash_hp).as_posix()] process = safer_popen( cmd + list(args), diff --git a/test/test_index.py b/test/test_index.py index 3ad5a457f..d902bf58d 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1128,16 +1128,20 @@ def test_run_commit_hook_outside_worktree_on_windows(self, rw_dir): repo = Repo.init(root / "repo") hooks_dir = root / "hooks" _make_hook(root, "fake-hook", "exit 0") + bash = root / "git" / "bin" / "bash.exe" with repo.config_writer() as writer: writer.set_value("core", "hooksPath", str(hooks_dir)) - with mock.patch("git.index.fun.sys.platform", "win32"): + with mock.patch("git.index.fun.sys.platform", "win32"), mock.patch( + "git.index.fun._which_from_path", return_value=str(bash) + ) as which: with mock.patch("git.index.fun.safer_popen") as popen, mock.patch("git.index.fun.handle_process_output"): popen.return_value.returncode = 0 run_commit_hook("fake-hook", repo.index) + which.assert_called_once_with("bash.exe") command = popen.call_args[0][0] - self.assertEqual(command, ["bash.exe", "../hooks/fake-hook"]) + self.assertEqual(command, [str(bash), "../hooks/fake-hook"]) @ddt.data((False,), (True,)) @with_rw_directory