Skip to content

Commit 7131bcc

Browse files
Byroncodex
andcommitted
Resolve Windows hook Bash through PATH (#2198)
<!-- agent --> 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 <codex@openai.com>
1 parent 04960cf commit 7131bcc

2 files changed

Lines changed: 26 additions & 3 deletions

File tree

git/index/fun.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ def _has_file_extension(path: str) -> str:
7979
return osp.splitext(path)[1]
8080

8181

82+
def _which_from_path(command: str) -> Union[str, None]:
83+
"""Resolve an executable 'command' from PATH without considering the current directory."""
84+
cwd = osp.normcase(osp.abspath(os.curdir))
85+
for directory in os.get_exec_path():
86+
if not directory:
87+
continue
88+
directory = osp.abspath(directory)
89+
if osp.normcase(directory) == cwd:
90+
continue
91+
candidate = osp.join(directory, command)
92+
if osp.isfile(candidate) and os.access(candidate, os.X_OK):
93+
return candidate
94+
return None
95+
96+
8297
def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None:
8398
"""Run the commit hook of the given name. Silently ignore hooks that do not exist.
8499
@@ -112,7 +127,11 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None:
112127
# an absolute path in this form, although a relative path is preferable
113128
# because it also works with the Windows Subsystem for Linux wrapper.
114129
bash_hp = hp
115-
cmd = ["bash.exe", Path(bash_hp).as_posix()]
130+
# Resolve through PATH before spawning. On Windows, CreateProcess searches
131+
# the current and system directories before PATH for a bare executable name,
132+
# which can otherwise select an impostor or the WSL launcher instead of Git
133+
# for Windows' Bash.
134+
cmd = [_which_from_path("bash.exe") or "bash.exe", Path(bash_hp).as_posix()]
116135

117136
process = safer_popen(
118137
cmd + list(args),

test/test_index.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,16 +1128,20 @@ def test_run_commit_hook_outside_worktree_on_windows(self, rw_dir):
11281128
repo = Repo.init(root / "repo")
11291129
hooks_dir = root / "hooks"
11301130
_make_hook(root, "fake-hook", "exit 0")
1131+
bash = root / "git" / "bin" / "bash.exe"
11311132
with repo.config_writer() as writer:
11321133
writer.set_value("core", "hooksPath", str(hooks_dir))
11331134

1134-
with mock.patch("git.index.fun.sys.platform", "win32"):
1135+
with mock.patch("git.index.fun.sys.platform", "win32"), mock.patch(
1136+
"git.index.fun._which_from_path", return_value=str(bash)
1137+
) as which:
11351138
with mock.patch("git.index.fun.safer_popen") as popen, mock.patch("git.index.fun.handle_process_output"):
11361139
popen.return_value.returncode = 0
11371140
run_commit_hook("fake-hook", repo.index)
11381141

1142+
which.assert_called_once_with("bash.exe")
11391143
command = popen.call_args[0][0]
1140-
self.assertEqual(command, ["bash.exe", "../hooks/fake-hook"])
1144+
self.assertEqual(command, [str(bash), "../hooks/fake-hook"])
11411145

11421146
@ddt.data((False,), (True,))
11431147
@with_rw_directory

0 commit comments

Comments
 (0)