Skip to content

Commit f4277fa

Browse files
authored
Merge pull request #2201 from gitpython-developers/fixup-bash-exe-lookup-on-windows
Correct Windows hook Bash precedence
2 parents 974e38b + b6f4a75 commit f4277fa

2 files changed

Lines changed: 167 additions & 5 deletions

File tree

git/index/fun.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from gitdb.base import IStream
2727
from gitdb.typ import str_tree_type
2828

29-
from git.cmd import handle_process_output, safer_popen
29+
from git.cmd import Git, handle_process_output, safer_popen
3030
from git.compat import defenc, force_bytes, force_text, safe_decode
3131
from git.exc import HookExecutionError, UnmergedEntriesError
3232
from git.objects.fun import (
@@ -79,6 +79,99 @@ def _has_file_extension(path: str) -> str:
7979
return osp.splitext(path)[1]
8080

8181

82+
def _is_in_windows_system_root(path: str) -> bool:
83+
"""Return whether ``path`` is inside the Windows installation directory."""
84+
system_root = os.environ.get("SystemRoot")
85+
if not system_root:
86+
return False
87+
88+
system_root = osp.normcase(osp.realpath(system_root))
89+
path = osp.normcase(osp.realpath(path))
90+
try:
91+
return osp.commonpath((system_root, path)) == system_root
92+
except ValueError:
93+
# Paths on different drives have no common path on Windows.
94+
return False
95+
96+
97+
def _which_from_path(command: str) -> Union[str, None]:
98+
"""Resolve ``command`` from PATH, excluding the Windows installation."""
99+
for directory in os.get_exec_path():
100+
# Unlike POSIX, Windows does not define an empty PATH entry as the current
101+
# directory. Skip it rather than letting abspath() turn it into one.
102+
if not directory:
103+
continue
104+
directory = osp.abspath(directory)
105+
candidate = osp.join(directory, command)
106+
# SystemRoot contains the WSL launcher stubs. They are valid executables but
107+
# not suitable for running a Windows Git hook: the hook path and environment
108+
# were prepared for Git for Windows, and WSL may have no distribution at all.
109+
if _is_in_windows_system_root(candidate):
110+
continue
111+
if osp.isfile(candidate) and os.access(candidate, os.X_OK):
112+
return candidate
113+
return None
114+
115+
116+
_GIT_FOR_WINDOWS_PREFIXES = ("mingw64", "mingw32", "clangarm64", "clang64", "clang32", "ucrt64")
117+
118+
119+
def _git_for_windows_root() -> Union[str, None]:
120+
"""Infer a standard Git for Windows root from GitPython's selected executable."""
121+
git_executable = os.fspath(Git.GIT_PYTHON_GIT_EXECUTABLE or Git.git_exec_name)
122+
if osp.dirname(git_executable):
123+
# CreateProcess resolves a relative executable path containing a directory
124+
# from the parent process cwd, even when Popen supplies a different child cwd.
125+
git_executable = osp.abspath(git_executable)
126+
else:
127+
# GitPython deliberately retains a bare executable name so later PATH changes
128+
# affect Git commands. Resolve it with the same PATH snapshot used for Bash.
129+
names = (git_executable,) if _has_file_extension(git_executable) else (git_executable, f"{git_executable}.exe")
130+
for name in names:
131+
resolved = _which_from_path(name)
132+
if resolved is not None:
133+
git_executable = resolved
134+
break
135+
else:
136+
git_executable = ""
137+
if not git_executable:
138+
return None
139+
if osp.basename(git_executable).lower() not in ("git", "git.exe"):
140+
return None
141+
142+
executable_dir = osp.dirname(git_executable)
143+
directory_name = osp.basename(executable_dir).lower()
144+
if directory_name == "cmd":
145+
# The normal system-wide PATH entry is <git-root>/cmd.
146+
return osp.dirname(executable_dir)
147+
if directory_name == "bin":
148+
prefix = osp.dirname(executable_dir)
149+
if osp.basename(prefix).lower() in _GIT_FOR_WINDOWS_PREFIXES:
150+
# Git Bash commonly exposes <git-root>/<platform>/bin/git.exe.
151+
return osp.dirname(prefix)
152+
if osp.basename(prefix).lower() != "usr":
153+
# An explicitly configured Git may be the root-level bin/git.exe. Do
154+
# not make the same inference from usr/bin: unlike the recognized
155+
# platform prefixes, "usr" has no reliably bounded parent layout.
156+
return prefix
157+
return None
158+
159+
160+
def _git_for_windows_bash() -> Union[str, None]:
161+
"""Return Bash from the Git for Windows installation selected by GitPython."""
162+
git_root = _git_for_windows_root()
163+
if git_root is None:
164+
return None
165+
166+
# Match gix-path's precedence: prefer the lightweight bin shim, then the
167+
# underlying usr/bin executable. Both belong to the same installation as Git.
168+
for relative_path in ("bin/bash.exe", "usr/bin/bash.exe"):
169+
candidate = osp.join(git_root, *relative_path.split("/"))
170+
if osp.isfile(candidate) and os.access(candidate, os.X_OK):
171+
return candidate
172+
return None
173+
174+
82175
def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None:
83176
"""Run the commit hook of the given name. Silently ignore hooks that do not exist.
84177
@@ -112,7 +205,12 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None:
112205
# an absolute path in this form, although a relative path is preferable
113206
# because it also works with the Windows Subsystem for Linux wrapper.
114207
bash_hp = hp
115-
cmd = ["bash.exe", Path(bash_hp).as_posix()]
208+
# Prefer Bash associated with GitPython's selected Git installation. If
209+
# that layout is not recognized, use an explicitly configured non-system
210+
# PATH entry. Preserve the bare fallback for installations that previously
211+
# relied on WSL or another CreateProcess-resolved Bash.
212+
bash_executable = _git_for_windows_bash() or _which_from_path("bash.exe") or "bash.exe"
213+
cmd = [bash_executable, Path(bash_hp).as_posix()]
116214

117215
process = safer_popen(
118216
cmd + list(args),

test/test_index.py

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
UnmergedEntriesError,
3434
UnsafeOptionError,
3535
)
36-
from git.index.fun import hook_path, run_commit_hook
36+
from git.index.fun import _git_for_windows_bash, _which_from_path, hook_path, run_commit_hook
3737
from git.index.typ import BaseIndexEntry, IndexEntry
3838
from git.index.util import TemporaryFileSwap
3939
from git.objects import Blob
@@ -1166,16 +1166,80 @@ def test_run_commit_hook_outside_worktree_on_windows(self, rw_dir):
11661166
repo = Repo.init(root / "repo")
11671167
hooks_dir = root / "hooks"
11681168
_make_hook(root, "fake-hook", "exit 0")
1169+
system_root = root / "Windows"
1170+
system_bash = system_root / "System32" / "bash.exe"
1171+
git_executable = root / "Git" / "cmd" / "git.exe"
1172+
git_bash = root / "Git" / "bin" / "bash.exe"
1173+
for executable in (system_bash, git_executable, git_bash):
1174+
executable.parent.mkdir(parents=True)
1175+
executable.touch()
1176+
executable.chmod(0o755)
11691177
with repo.config_writer() as writer:
11701178
writer.set_value("core", "hooksPath", str(hooks_dir))
11711179

1172-
with mock.patch("git.index.fun.sys.platform", "win32"):
1180+
# Model a normal Windows PATH: System32 (containing the WSL launcher) comes
1181+
# before Git's cmd directory, while Git's Bash is not itself on PATH. This
1182+
# exercises both Git-installation discovery and shell selection without
1183+
# mocking either resolver's answer.
1184+
with mock.patch("git.index.fun.sys.platform", "win32"), mock.patch.object(
1185+
Git, "GIT_PYTHON_GIT_EXECUTABLE", "git"
1186+
), mock.patch.dict(os.environ, {"SystemRoot": str(system_root)}), mock.patch(
1187+
"git.index.fun.os.get_exec_path", return_value=["", str(system_bash.parent), str(git_executable.parent)]
1188+
):
11731189
with mock.patch("git.index.fun.safer_popen") as popen, mock.patch("git.index.fun.handle_process_output"):
11741190
popen.return_value.returncode = 0
11751191
run_commit_hook("fake-hook", repo.index)
11761192

11771193
command = popen.call_args[0][0]
1178-
self.assertEqual(command, ["bash.exe", "../hooks/fake-hook"])
1194+
self.assertEqual(command, [str(git_bash), "../hooks/fake-hook"])
1195+
1196+
@with_rw_directory
1197+
def test_windows_bash_lookup_respects_explicit_current_directory_in_path(self, rw_dir):
1198+
root = Path(rw_dir).resolve()
1199+
bash = root / "bash.exe"
1200+
bash.touch()
1201+
bash.chmod(0o755)
1202+
1203+
# An explicitly listed directory is trusted PATH configuration, even when
1204+
# it happens to be the current directory. This differs from an empty entry,
1205+
# which Windows requires PATH lookup to ignore.
1206+
with cwd(root), mock.patch("git.index.fun.os.get_exec_path", return_value=[str(root)]):
1207+
self.assertEqual(_which_from_path("bash.exe"), str(bash))
1208+
1209+
@with_rw_directory
1210+
def test_windows_bash_lookup_from_explicit_git_bin(self, rw_dir):
1211+
git_root = Path(rw_dir).resolve() / "Git"
1212+
git_executable = git_root / "bin" / "git.exe"
1213+
bash = git_root / "bin" / "bash.exe"
1214+
git_executable.parent.mkdir(parents=True)
1215+
for executable in (git_executable, bash):
1216+
executable.touch()
1217+
executable.chmod(0o755)
1218+
1219+
# A relative executable containing a directory is resolved by CreateProcess
1220+
# from the parent process cwd, not the separately supplied child cwd. Enter the
1221+
# temporary root first because Windows cannot express a relative path between
1222+
# drives, and CI may keep the checkout and its temporary directory on different
1223+
# drives.
1224+
with cwd(Path(rw_dir).resolve()):
1225+
relative_git = osp.relpath(git_executable, os.curdir)
1226+
with mock.patch.object(Git, "GIT_PYTHON_GIT_EXECUTABLE", relative_git):
1227+
self.assertEqual(_git_for_windows_bash(), str(bash))
1228+
1229+
@with_rw_directory
1230+
def test_windows_bash_lookup_ignores_custom_git_executable(self, rw_dir):
1231+
root = Path(rw_dir).resolve()
1232+
for directory_name in ("cmd", "bin"):
1233+
executable = root / directory_name / "mygit.exe"
1234+
bash = root / "bin" / "bash.exe"
1235+
executable.parent.mkdir(parents=True, exist_ok=True)
1236+
bash.parent.mkdir(parents=True, exist_ok=True)
1237+
executable.touch()
1238+
bash.touch()
1239+
executable.chmod(0o755)
1240+
bash.chmod(0o755)
1241+
with mock.patch.object(Git, "GIT_PYTHON_GIT_EXECUTABLE", str(executable)):
1242+
self.assertIsNone(_git_for_windows_bash())
11791243

11801244
@ddt.data((False,), (True,))
11811245
@with_rw_directory

0 commit comments

Comments
 (0)