diff --git a/git/index/fun.py b/git/index/fun.py index 629c19b1e..64785ec12 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -81,8 +81,19 @@ 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 diff --git a/test/test_index.py b/test/test_index.py index 3be750dbb..79a744f31 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1242,6 +1242,95 @@ 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") + # 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(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.working_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") + # 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(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.working_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)