From 7977004dd8169605c572ac7a00325700d89bdd28 Mon Sep 17 00:00:00 2001 From: Paul Date: Sun, 7 Dec 2025 20:59:58 +0530 Subject: [PATCH 1/4] Fix commit hooks to respect core.hooksPath configuration Fixes #2083 The run_commit_hook() function was hardcoded to look for hooks in $GIT_DIR/hooks, ignoring the core.hooksPath configuration option that Git has supported since v2.9. Changes: - Add _get_hooks_dir() helper that reads core.hooksPath from config - Handle both absolute and relative paths per git-config documentation - Update run_commit_hook() to use the new helper - Add comprehensive tests for both absolute and relative hooksPath Per git-config documentation: - Absolute paths are used as-is - Relative paths are resolved relative to the working tree root (or git_dir for bare repos) - If not set, defaults to $GIT_DIR/hooks The existing hook_path() function is preserved for backwards compatibility and documented to note it does not respect the config. --- git/index/fun.py | 43 +++++++++++++++++++++++-- test/test_index.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/git/index/fun.py b/git/index/fun.py index 629c19b1e..f8d7f367e 100644 --- a/git/index/fun.py +++ b/git/index/fun.py @@ -60,10 +60,48 @@ 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 + """ + # Import here to avoid circular imports + from git.repo.base import Repo + + 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 +120,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..5256410aa 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1055,6 +1055,86 @@ 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") + @ddt.data((False,), (True,)) @with_rw_directory def test_hook_uses_shell_not_from_cwd(self, rw_dir, case): From 1a1022681fb76c6ea17fcd8d5aabdf6e43145cf1 Mon Sep 17 00:00:00 2001 From: Paul Desai Date: Mon, 8 Dec 2025 13:55:35 +0530 Subject: [PATCH 2/4] Add test for core.hooksPath in bare repositories Also clean up whitespace and move type import to TYPE_CHECKING block. --- git/index/fun.py | 12 +++++------- test/test_index.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/git/index/fun.py b/git/index/fun.py index f8d7f367e..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 @@ -61,7 +62,7 @@ def hook_path(name: str, git_dir: PathLike) -> str: """: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. """ @@ -70,25 +71,22 @@ def hook_path(name: str, git_dir: PathLike) -> str: 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 """ - # Import here to avoid circular imports - from git.repo.base import Repo - 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): diff --git a/test/test_index.py b/test/test_index.py index 5256410aa..8cd26c624 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1135,6 +1135,48 @@ def test_run_commit_hook_respects_relative_core_hookspath(self, rw_repo): 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 (use absolute path for bare repo) + # Use a unique name based on the repo to avoid conflicts + custom_hooks_dir = Path(rw_repo.git_dir).parent / "bare-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 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): From 7b1acdb713e8dbfd7f1f7d13cc2407457de6a82a Mon Sep 17 00:00:00 2001 From: Paul Desai Date: Mon, 8 Dec 2025 13:57:44 +0530 Subject: [PATCH 3/4] Fix ruff lint: remove whitespace from blank lines in docstrings --- test/test_index.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/test/test_index.py b/test/test_index.py index 8cd26c624..befc4ec0f 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1068,28 +1068,28 @@ def test_run_commit_hook(self, rw_repo): @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") @@ -1108,28 +1108,28 @@ def test_run_commit_hook_respects_core_hookspath(self, rw_repo): @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") @@ -1158,19 +1158,19 @@ def test_run_commit_hook_respects_core_hookspath_bare_repo(self, rw_repo): # Use a unique name based on the repo to avoid conflicts custom_hooks_dir = Path(rw_repo.git_dir).parent / "bare-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 git_dir output_file = Path(rw_repo.git_dir) / "output.txt" self.assertTrue(output_file.exists(), "Hook should have created output.txt") From 6e58a6994d5346ae702a7de96d9739ba585a94d6 Mon Sep 17 00:00:00 2001 From: Paul Desai Date: Mon, 8 Dec 2025 14:23:11 +0530 Subject: [PATCH 4/4] Fix bare repo test: use custom-hooks dir inside git_dir Place hooks inside git_dir to avoid path resolution issues on Windows where absolute paths outside the repo directory fail the relative_to check. --- test/test_index.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/test_index.py b/test/test_index.py index befc4ec0f..9552be46c 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -1154,9 +1154,9 @@ def test_run_commit_hook_respects_core_hookspath_bare_repo(self, rw_repo): """ index = rw_repo.index - # Create a custom hooks directory (use absolute path for bare repo) - # Use a unique name based on the repo to avoid conflicts - custom_hooks_dir = Path(rw_repo.git_dir).parent / "bare-custom-hooks" + # 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 @@ -1171,7 +1171,7 @@ def test_run_commit_hook_respects_core_hookspath_bare_repo(self, rw_repo): # 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 git_dir + # 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")