diff --git a/git/index/fun.py b/git/index/fun.py index 629c19b1e..f36d3009a 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -48,6 +48,7 @@ if TYPE_CHECKING: from git.db import GitCmdObjectDB from git.objects.tree import TreeCacheTup + from git.repo import Repo from .base import IndexFile @@ -60,10 +61,45 @@ def hook_path(name: str, git_dir: PathLike) -> str: - """:return: path to the given named hook in the given git repository directory""" + """:return: path to the given named hook in the given git repository directory + + Note: This function does not respect the core.hooksPath configuration. + For commit hooks that should respect this config, use run_commit_hook() instead. + """ return osp.join(git_dir, "hooks", name) +def _get_hooks_dir(repo: "Repo") -> str: + """Get the hooks directory, respecting core.hooksPath configuration. + + :param repo: The repository to get the hooks directory for. + :return: Path to the hooks directory. + + Per git-config documentation, core.hooksPath can be: + - An absolute path: used as-is + - A relative path: relative to the directory where hooks are run from + (typically the working tree root for non-bare repos) + - If not set: defaults to $GIT_DIR/hooks + """ + try: + hooks_path = repo.config_reader().get_value("core", "hooksPath") + except Exception: + # Config key not found or other error - use default + hooks_path = None + + if hooks_path: + hooks_path = str(hooks_path) + if osp.isabs(hooks_path): + return hooks_path + else: + # Relative paths are relative to the working tree (or git_dir for bare repos) + base_dir = repo.working_tree_dir if repo.working_tree_dir else repo.git_dir + return osp.normpath(osp.join(base_dir, hooks_path)) + else: + # Default: $GIT_DIR/hooks + return osp.join(repo.git_dir, "hooks") + + def _has_file_extension(path: str) -> str: return osp.splitext(path)[1] @@ -82,7 +118,8 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None: :raise git.exc.HookExecutionError: """ - hp = hook_path(name, index.repo.git_dir) + hooks_dir = _get_hooks_dir(index.repo) + hp = osp.join(hooks_dir, name) if not os.access(hp, os.X_OK): return diff --git a/test/test_index.py b/test/test_index.py index 33490f907..9552be46c 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1055,6 +1055,128 @@ def test_run_commit_hook(self, rw_repo): output = Path(rw_repo.git_dir, "output.txt").read_text(encoding="utf-8") self.assertEqual(output, "ran fake 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=False) + def test_run_commit_hook_respects_core_hookspath(self, rw_repo): + """Test that run_commit_hook() respects core.hooksPath configuration. + + This addresses issue #2083 where commit hooks were always looked for in + $GIT_DIR/hooks instead of respecting the core.hooksPath config setting. + """ + index = rw_repo.index + + # Create a custom hooks directory outside of .git + custom_hooks_dir = Path(rw_repo.working_tree_dir) / "custom-hooks" + custom_hooks_dir.mkdir() + + # Create a hook in the custom location + custom_hook = custom_hooks_dir / "fake-hook" + custom_hook.write_text(HOOKS_SHEBANG + "echo 'ran from custom hooks path' >output.txt") + custom_hook.chmod(0o744) + + # Set core.hooksPath in the repo config + with rw_repo.config_writer() as config: + config.set_value("core", "hooksPath", str(custom_hooks_dir)) + + # Run the hook - it should use the custom path + run_commit_hook("fake-hook", index) + + output_file = Path(rw_repo.working_tree_dir) / "output.txt" + self.assertTrue(output_file.exists(), "Hook should have created output.txt") + output = output_file.read_text(encoding="utf-8") + self.assertEqual(output, "ran from custom hooks path\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=False) + def test_run_commit_hook_respects_relative_core_hookspath(self, rw_repo): + """Test that run_commit_hook() handles relative core.hooksPath correctly. + + Per git-config docs, relative paths for core.hooksPath are relative to + the directory where hooks are run (typically the working tree root). + """ + index = rw_repo.index + + # Create a custom hooks directory with a relative path + custom_hooks_dir = Path(rw_repo.working_tree_dir) / "relative-hooks" + custom_hooks_dir.mkdir() + + # Create a hook in the custom location + custom_hook = custom_hooks_dir / "fake-hook" + custom_hook.write_text(HOOKS_SHEBANG + "echo 'ran from relative hooks path' >output.txt") + custom_hook.chmod(0o744) + + # Set core.hooksPath to a relative path + with rw_repo.config_writer() as config: + config.set_value("core", "hooksPath", "relative-hooks") + + # Run the hook - it should resolve the relative path correctly + run_commit_hook("fake-hook", index) + + output_file = Path(rw_repo.working_tree_dir) / "output.txt" + self.assertTrue(output_file.exists(), "Hook should have created output.txt") + output = output_file.read_text(encoding="utf-8") + self.assertEqual(output, "ran from relative hooks path\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_core_hookspath_bare_repo(self, rw_repo): + """Test that run_commit_hook() respects core.hooksPath in bare repositories. + + For bare repos, relative paths should be resolved relative to git_dir since + there is no working tree. + """ + index = rw_repo.index + + # Create a custom hooks directory inside the git_dir for bare repo + # This ensures the path is relative to working_dir (which equals git_dir for bare repos) + custom_hooks_dir = Path(rw_repo.git_dir) / "custom-hooks" + custom_hooks_dir.mkdir(exist_ok=True) + + # Create a hook in the custom location + custom_hook = custom_hooks_dir / "fake-hook" + custom_hook.write_text(HOOKS_SHEBANG + "echo 'ran from custom hooks path in bare repo' >output.txt") + custom_hook.chmod(0o744) + + # Set core.hooksPath in the repo config (absolute path) + with rw_repo.config_writer() as config: + config.set_value("core", "hooksPath", str(custom_hooks_dir)) + + # Run the hook - it should use the custom path + run_commit_hook("fake-hook", index) + + # Output goes to cwd, which for bare repos during hook execution is working_dir (same as git_dir) + output_file = Path(rw_repo.git_dir) / "output.txt" + self.assertTrue(output_file.exists(), "Hook should have created output.txt") + output = output_file.read_text(encoding="utf-8") + self.assertEqual(output, "ran from custom hooks path in bare repo\n") + @ddt.data((False,), (True,)) @with_rw_directory def test_hook_uses_shell_not_from_cwd(self, rw_dir, case):