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
21 changes: 15 additions & 6 deletions git/index/fun.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,19 +198,28 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None:
if sys.platform == "win32" and not _has_file_extension(hp):
# Windows only uses extensions to determine how to open files
# (doesn't understand shebangs). Try using bash to run the hook.
# Prefer Bash associated with GitPython's selected Git installation. If
# that layout is not recognized, use an explicitly configured non-system
# PATH entry. Preserve the bare fallback for installations that previously
# relied on WSL or another CreateProcess-resolved Bash.
found_bash = _git_for_windows_bash() or _which_from_path("bash.exe")
try:
bash_hp = osp.relpath(hp, index.repo.working_dir)
except ValueError:
# Different drives have no relative path on Windows. Git Bash accepts
# 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
# Prefer Bash associated with GitPython's selected Git installation. If
# that layout is not recognized, use an explicitly configured non-system
# PATH entry. Preserve the bare fallback for installations that previously
# relied on WSL or another CreateProcess-resolved Bash.
bash_executable = _git_for_windows_bash() or _which_from_path("bash.exe") or "bash.exe"
cmd = [bash_executable, Path(bash_hp).as_posix()]
else:
# Bash resolves a relative path in its own view of the filesystem,
# which differs from Windows' across a mount point: Git Bash mounts
# the user's temporary directory as /tmp, so from a working tree
# there, ".." leads elsewhere. A path leaving the working tree is
# thus passed whole to a Bash found above, which accepts it; the
# bare fallback may be WSL's, which does not, and keeps it relative.
if found_bash and bash_hp.startswith(os.pardir + os.sep):
bash_hp = hp
cmd = [found_bash or "bash.exe", Path(bash_hp).as_posix()]

process = safer_popen(
cmd + list(args),
Expand Down
62 changes: 49 additions & 13 deletions test/test_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -1513,6 +1513,7 @@ 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")
_make_hook(repo.working_dir, "fake-hook", "exit 0")
system_root = root / "Windows"
system_bash = system_root / "System32" / "bash.exe"
git_executable = root / "Git" / "cmd" / "git.exe"
Expand All @@ -1521,24 +1522,37 @@ def test_run_commit_hook_outside_worktree_on_windows(self, rw_dir):
executable.parent.mkdir(parents=True)
executable.touch()
executable.chmod(0o755)
with repo.config_writer() as writer:
writer.set_value("core", "hooksPath", str(hooks_dir))

def command(hooks_path, exec_path):
with repo.config_writer() as writer:
writer.set_value("core", "hooksPath", hooks_path)
with mock.patch("git.index.fun.sys.platform", "win32"), mock.patch.object(
Git, "GIT_PYTHON_GIT_EXECUTABLE", "git"
), mock.patch.dict(os.environ, {"SystemRoot": str(system_root)}), mock.patch(
"git.index.fun.os.get_exec_path", return_value=exec_path
):
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)
return popen.call_args[0][0]

# Model a normal Windows PATH: System32 (containing the WSL launcher) comes
# before Git's cmd directory, while Git's Bash is not itself on PATH. This
# exercises both Git-installation discovery and shell selection without
# mocking either resolver's answer.
with mock.patch("git.index.fun.sys.platform", "win32"), mock.patch.object(
Git, "GIT_PYTHON_GIT_EXECUTABLE", "git"
), mock.patch.dict(os.environ, {"SystemRoot": str(system_root)}), mock.patch(
"git.index.fun.os.get_exec_path", return_value=["", str(system_bash.parent), str(git_executable.parent)]
):
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)

command = popen.call_args[0][0]
self.assertEqual(command, [str(git_bash), "../hooks/fake-hook"])
with_git = ["", str(system_bash.parent), str(git_executable.parent)]
# A hook outside the working tree is passed by its absolute path: a relative
# one is resolved in Bash's own view of the filesystem, where a mount point
# (Git Bash's /tmp) can make ".." lead elsewhere.
self.assertEqual(command(str(hooks_dir), with_git), [str(git_bash), (hooks_dir / "fake-hook").as_posix()])
# A hook inside the working tree keeps its relative path.
self.assertEqual(command("hooks", with_git), [str(git_bash), "hooks/fake-hook"])
# So does one outside it for the bare fallback, which may be WSL's bash.exe,
# unable to open a Windows absolute path.
without_git = ["", str(system_bash.parent)]
self.assertEqual(command(str(hooks_dir), without_git), ["bash.exe", "../hooks/fake-hook"])

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

@pytest.mark.xfail(
type(_win_bash_status) is WinBashStatus.Absent,
reason="Can't run a hook on Windows without bash.exe.",
raises=HookExecutionError,
)
@pytest.mark.xfail(
type(_win_bash_status) is WinBashStatus.WslNoDistro,
reason="Currently uses the bash.exe of WSL, even with no WSL distro installed",
raises=HookExecutionError,
)
@with_rw_directory
def test_pre_commit_hook_outside_worktree(self, rw_dir):
repo = Repo.init(osp.join(rw_dir, "repo"))
hp = _make_hook(rw_dir, "pre-commit", "echo 'ran outside hook' >outside-hook-output.txt")

with repo.config_writer() as writer:
writer.set_value("core", "hooksPath", osp.dirname(hp))

repo.index.commit("This should run the hook outside the working tree")
output = Path(repo.working_dir, "outside-hook-output.txt").read_text(encoding="utf-8")
self.assertEqual(output, "ran outside hook\n")

@pytest.mark.xfail(
type(_win_bash_status) is WinBashStatus.WslNoDistro,
reason="Currently uses the bash.exe of WSL, even with no WSL distro installed",
Expand Down
Loading