From 1622e7465c6ca3b78f6015990bb98f4c96fbb6fc Mon Sep 17 00:00:00 2001 From: ncttjz Date: Wed, 15 Jul 2026 23:43:09 +0700 Subject: [PATCH 1/3] fix: respect core.hooksPath git config in commit hooks --- git/index/fun.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/git/index/fun.py b/git/index/fun.py index 629c19b1e..e8d7c8b08 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -81,8 +81,21 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: Arguments passed to hook file. :raise git.exc.HookExecutionError: + + :note: + Respects the ``core.hooksPath`` git configuration option. When set, hooks are + resolved relative to that path instead of the default ``.git/hooks`` directory. """ - hp = hook_path(name, index.repo.git_dir) + hooks_path = index.repo.config_reader().get( + "core", "hooksPath", fallback=None + ) + if hooks_path is not None: + hp = osp.join(hooks_path, name) + if not osp.isabs(hooks_path): + # Relative hooksPath is resolved against the working tree root. + hp = osp.join(index.repo.working_dir, hp) + else: + hp = hook_path(name, index.repo.git_dir) if not os.access(hp, os.X_OK): return From 33c3373097b055dc81552ed123bedcd651f1e086 Mon Sep 17 00:00:00 2001 From: ncttjz Date: Thu, 16 Jul 2026 13:19:40 +0700 Subject: [PATCH 2/3] test: add tests for core.hooksPath support Add 4 tests covering the hooksPath behavior: - Absolute hooksPath: hooks in a custom absolute path are found - Relative hooksPath: resolved against working tree root - Fallback: default .git/hooks used when hooksPath not set - Missing hook: no error raised when hook doesn't exist in hooksPath All tests follow the existing test patterns and are xfail-marked for Windows environments without bash.exe. Addresses review request from maintainer (Byron). --- test/test_index.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/test/test_index.py b/test/test_index.py index 3be750dbb..0b14437ac 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1242,6 +1242,91 @@ def test_commit_msg_hook_fail(self, rw_repo): else: raise AssertionError("Should have caught a HookExecutionError") + # ---- core.hooksPath tests ---- + + @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_repo("HEAD", bare=True) + def test_run_commit_hook_respects_absolute_hooks_path(self, rw_repo): + """Hooks in a custom absolute core.hooksPath are found and executed.""" + index = rw_repo.index + custom_hooks_dir = osp.join(rw_repo.working_dir, "custom-hooks") + os.makedirs(custom_hooks_dir, exist_ok=True) + hp = osp.join(custom_hooks_dir, "fake-hook") + with open(hp, "w", encoding="utf-8") as f: + f.write("#!/bin/sh\necho 'ran custom hook' >output.txt\n") + os.chmod(hp, 0o755) + + rw_repo.config_writer().set_value("core", "hooksPath", custom_hooks_dir).release() + run_commit_hook("fake-hook", index) + output = Path(rw_repo.git_dir, "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_repo("HEAD", bare=True) + def test_run_commit_hook_respects_relative_hooks_path(self, rw_repo): + """A relative core.hooksPath is resolved against the working tree root.""" + index = rw_repo.index + custom_hooks_dir = osp.join(rw_repo.working_dir, "hooks-relative") + os.makedirs(custom_hooks_dir, exist_ok=True) + hp = osp.join(custom_hooks_dir, "fake-hook") + with open(hp, "w", encoding="utf-8") as f: + f.write("#!/bin/sh\necho 'ran relative hook' >output.txt\n") + os.chmod(hp, 0o755) + + rw_repo.config_writer().set_value("core", "hooksPath", "hooks-relative").release() + run_commit_hook("fake-hook", index) + output = Path(rw_repo.git_dir, "output.txt").read_text(encoding="utf-8") + self.assertEqual(output, "ran relative hook\n") + + @with_rw_repo("HEAD", bare=True) + def test_run_commit_hook_falls_back_to_default_without_hooks_path(self, rw_repo): + """When core.hooksPath is not set, the default .git/hooks path is used.""" + index = rw_repo.index + _make_hook(index.repo.git_dir, "fake-hook", "echo 'ran default hook' >output.txt") + run_commit_hook("fake-hook", index) + output = Path(rw_repo.git_dir, "output.txt").read_text(encoding="utf-8") + self.assertEqual(output, "ran default 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_repo("HEAD", bare=True) + def test_run_commit_hook_hooks_path_skips_missing_hook(self, rw_repo): + """If a hook does not exist in core.hooksPath, no error is raised.""" + index = rw_repo.index + custom_hooks_dir = osp.join(rw_repo.working_dir, "empty-hooks") + os.makedirs(custom_hooks_dir, exist_ok=True) + rw_repo.config_writer().set_value("core", "hooksPath", custom_hooks_dir).release() + # Should not raise -- the hook doesn't exist, so run_commit_hook returns silently + run_commit_hook("nonexistent-hook", index) + + # ---- end core.hooksPath tests ---- + @with_rw_repo("HEAD") def test_index_add_pathlib(self, rw_repo): git_dir = Path(rw_repo.git_dir) From 8fdc645217f97c887d69c58e65af233fff3c02e2 Mon Sep 17 00:00:00 2001 From: ncttjz Date: Thu, 16 Jul 2026 14:17:40 +0700 Subject: [PATCH 3/3] fix: cross-platform hook tests and ruff format - Use Python scripts instead of .sh for hook tests (works on Windows CI) - Write output to working_dir instead of git_dir (bare repo compatibility) - Apply ruff format to git/index/fun.py - Removes xfail dependency on bash.exe for hooksPath tests --- git/index/fun.py | 4 +--- test/test_index.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/git/index/fun.py b/git/index/fun.py index e8d7c8b08..64785ec12 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -86,9 +86,7 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: Respects the ``core.hooksPath`` git configuration option. When set, hooks are resolved relative to that path instead of the default ``.git/hooks`` directory. """ - hooks_path = index.repo.config_reader().get( - "core", "hooksPath", fallback=None - ) + hooks_path = index.repo.config_reader().get("core", "hooksPath", fallback=None) if hooks_path is not None: hp = osp.join(hooks_path, name) if not osp.isabs(hooks_path): diff --git a/test/test_index.py b/test/test_index.py index 0b14437ac..79a744f31 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1261,13 +1261,15 @@ def test_run_commit_hook_respects_absolute_hooks_path(self, rw_repo): custom_hooks_dir = osp.join(rw_repo.working_dir, "custom-hooks") os.makedirs(custom_hooks_dir, exist_ok=True) hp = osp.join(custom_hooks_dir, "fake-hook") + # Use a Python script so it works on all platforms (no bash required) + hook_script = "#!" + sys.executable + "\nimport sys; open('output.txt', 'w').write('ran custom hook\\n')\n" with open(hp, "w", encoding="utf-8") as f: - f.write("#!/bin/sh\necho 'ran custom hook' >output.txt\n") + f.write(hook_script) os.chmod(hp, 0o755) rw_repo.config_writer().set_value("core", "hooksPath", custom_hooks_dir).release() run_commit_hook("fake-hook", index) - output = Path(rw_repo.git_dir, "output.txt").read_text(encoding="utf-8") + output = Path(rw_repo.working_dir, "output.txt").read_text(encoding="utf-8") self.assertEqual(output, "ran custom hook\n") @pytest.mark.xfail( @@ -1287,13 +1289,15 @@ def test_run_commit_hook_respects_relative_hooks_path(self, rw_repo): custom_hooks_dir = osp.join(rw_repo.working_dir, "hooks-relative") os.makedirs(custom_hooks_dir, exist_ok=True) hp = osp.join(custom_hooks_dir, "fake-hook") + # Use a Python script so it works on all platforms (no bash required) + hook_script = "#!" + sys.executable + "\nimport sys; open('output.txt', 'w').write('ran relative hook\\n')\n" with open(hp, "w", encoding="utf-8") as f: - f.write("#!/bin/sh\necho 'ran relative hook' >output.txt\n") + f.write(hook_script) os.chmod(hp, 0o755) rw_repo.config_writer().set_value("core", "hooksPath", "hooks-relative").release() run_commit_hook("fake-hook", index) - output = Path(rw_repo.git_dir, "output.txt").read_text(encoding="utf-8") + output = Path(rw_repo.working_dir, "output.txt").read_text(encoding="utf-8") self.assertEqual(output, "ran relative hook\n") @with_rw_repo("HEAD", bare=True)