Skip to content

Commit 108935d

Browse files
authored
Merge pull request #2288 from 6801318d8d/fix/windows-hook-path-across-mount
fix(index): pass a Windows hook outside the work tree by absolute path
2 parents 563cb1f + e264eaf commit 108935d

2 files changed

Lines changed: 64 additions & 19 deletions

File tree

‎git/index/fun.py‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -198,19 +198,28 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None:
198198
if sys.platform == "win32" and not _has_file_extension(hp):
199199
# Windows only uses extensions to determine how to open files
200200
# (doesn't understand shebangs). Try using bash to run the hook.
201+
# Prefer Bash associated with GitPython's selected Git installation. If
202+
# that layout is not recognized, use an explicitly configured non-system
203+
# PATH entry. Preserve the bare fallback for installations that previously
204+
# relied on WSL or another CreateProcess-resolved Bash.
205+
found_bash = _git_for_windows_bash() or _which_from_path("bash.exe")
201206
try:
202207
bash_hp = osp.relpath(hp, index.repo.working_dir)
203208
except ValueError:
204209
# Different drives have no relative path on Windows. Git Bash accepts
205210
# an absolute path in this form, although a relative path is preferable
206211
# because it also works with the Windows Subsystem for Linux wrapper.
207212
bash_hp = hp
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()]
213+
else:
214+
# Bash resolves a relative path in its own view of the filesystem,
215+
# which differs from Windows' across a mount point: Git Bash mounts
216+
# the user's temporary directory as /tmp, so from a working tree
217+
# there, ".." leads elsewhere. A path leaving the working tree is
218+
# thus passed whole to a Bash found above, which accepts it; the
219+
# bare fallback may be WSL's, which does not, and keeps it relative.
220+
if found_bash and bash_hp.startswith(os.pardir + os.sep):
221+
bash_hp = hp
222+
cmd = [found_bash or "bash.exe", Path(bash_hp).as_posix()]
214223

215224
process = safer_popen(
216225
cmd + list(args),

‎test/test_index.py‎

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,6 +1513,7 @@ def test_run_commit_hook_outside_worktree_on_windows(self, rw_dir):
15131513
repo = Repo.init(root / "repo")
15141514
hooks_dir = root / "hooks"
15151515
_make_hook(root, "fake-hook", "exit 0")
1516+
_make_hook(repo.working_dir, "fake-hook", "exit 0")
15161517
system_root = root / "Windows"
15171518
system_bash = system_root / "System32" / "bash.exe"
15181519
git_executable = root / "Git" / "cmd" / "git.exe"
@@ -1521,24 +1522,37 @@ def test_run_commit_hook_outside_worktree_on_windows(self, rw_dir):
15211522
executable.parent.mkdir(parents=True)
15221523
executable.touch()
15231524
executable.chmod(0o755)
1524-
with repo.config_writer() as writer:
1525-
writer.set_value("core", "hooksPath", str(hooks_dir))
1525+
1526+
def command(hooks_path, exec_path):
1527+
with repo.config_writer() as writer:
1528+
writer.set_value("core", "hooksPath", hooks_path)
1529+
with mock.patch("git.index.fun.sys.platform", "win32"), mock.patch.object(
1530+
Git, "GIT_PYTHON_GIT_EXECUTABLE", "git"
1531+
), mock.patch.dict(os.environ, {"SystemRoot": str(system_root)}), mock.patch(
1532+
"git.index.fun.os.get_exec_path", return_value=exec_path
1533+
):
1534+
with mock.patch("git.index.fun.safer_popen") as popen, mock.patch(
1535+
"git.index.fun.handle_process_output"
1536+
):
1537+
popen.return_value.returncode = 0
1538+
run_commit_hook("fake-hook", repo.index)
1539+
return popen.call_args[0][0]
15261540

15271541
# Model a normal Windows PATH: System32 (containing the WSL launcher) comes
15281542
# before Git's cmd directory, while Git's Bash is not itself on PATH. This
15291543
# exercises both Git-installation discovery and shell selection without
15301544
# mocking either resolver's answer.
1531-
with mock.patch("git.index.fun.sys.platform", "win32"), mock.patch.object(
1532-
Git, "GIT_PYTHON_GIT_EXECUTABLE", "git"
1533-
), mock.patch.dict(os.environ, {"SystemRoot": str(system_root)}), mock.patch(
1534-
"git.index.fun.os.get_exec_path", return_value=["", str(system_bash.parent), str(git_executable.parent)]
1535-
):
1536-
with mock.patch("git.index.fun.safer_popen") as popen, mock.patch("git.index.fun.handle_process_output"):
1537-
popen.return_value.returncode = 0
1538-
run_commit_hook("fake-hook", repo.index)
1539-
1540-
command = popen.call_args[0][0]
1541-
self.assertEqual(command, [str(git_bash), "../hooks/fake-hook"])
1545+
with_git = ["", str(system_bash.parent), str(git_executable.parent)]
1546+
# A hook outside the working tree is passed by its absolute path: a relative
1547+
# one is resolved in Bash's own view of the filesystem, where a mount point
1548+
# (Git Bash's /tmp) can make ".." lead elsewhere.
1549+
self.assertEqual(command(str(hooks_dir), with_git), [str(git_bash), (hooks_dir / "fake-hook").as_posix()])
1550+
# A hook inside the working tree keeps its relative path.
1551+
self.assertEqual(command("hooks", with_git), [str(git_bash), "hooks/fake-hook"])
1552+
# So does one outside it for the bare fallback, which may be WSL's bash.exe,
1553+
# unable to open a Windows absolute path.
1554+
without_git = ["", str(system_bash.parent)]
1555+
self.assertEqual(command(str(hooks_dir), without_git), ["bash.exe", "../hooks/fake-hook"])
15421556

15431557
@with_rw_directory
15441558
def test_windows_bash_lookup_respects_explicit_current_directory_in_path(self, rw_dir):
@@ -1671,6 +1685,28 @@ def test_pre_commit_hook_respects_core_hooks_path(self, rw_repo):
16711685
output = Path(rw_repo.working_dir, "custom-hook-output.txt").read_text(encoding="utf-8")
16721686
self.assertEqual(output, "ran custom hook\n")
16731687

1688+
@pytest.mark.xfail(
1689+
type(_win_bash_status) is WinBashStatus.Absent,
1690+
reason="Can't run a hook on Windows without bash.exe.",
1691+
raises=HookExecutionError,
1692+
)
1693+
@pytest.mark.xfail(
1694+
type(_win_bash_status) is WinBashStatus.WslNoDistro,
1695+
reason="Currently uses the bash.exe of WSL, even with no WSL distro installed",
1696+
raises=HookExecutionError,
1697+
)
1698+
@with_rw_directory
1699+
def test_pre_commit_hook_outside_worktree(self, rw_dir):
1700+
repo = Repo.init(osp.join(rw_dir, "repo"))
1701+
hp = _make_hook(rw_dir, "pre-commit", "echo 'ran outside hook' >outside-hook-output.txt")
1702+
1703+
with repo.config_writer() as writer:
1704+
writer.set_value("core", "hooksPath", osp.dirname(hp))
1705+
1706+
repo.index.commit("This should run the hook outside the working tree")
1707+
output = Path(repo.working_dir, "outside-hook-output.txt").read_text(encoding="utf-8")
1708+
self.assertEqual(output, "ran outside hook\n")
1709+
16741710
@pytest.mark.xfail(
16751711
type(_win_bash_status) is WinBashStatus.WslNoDistro,
16761712
reason="Currently uses the bash.exe of WSL, even with no WSL distro installed",

0 commit comments

Comments
 (0)