Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
  • Loading branch information
Byron and codex committed Jul 30, 2026
commit 7131bcc27f2d96851eb6d667dc1207ab6ece5c61
21 changes: 20 additions & 1 deletion git/index/fun.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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()]
Comment thread
EliahKagan marked this conversation as resolved.

process = safer_popen(
cmd + list(args),
Expand Down
8 changes: 6 additions & 2 deletions test/test_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading