Skip to content

Commit 7977004

Browse files
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.
1 parent eecc28d commit 7977004

File tree

2 files changed

+121
-2
lines changed

2 files changed

+121
-2
lines changed

git/index/fun.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,48 @@
6060

6161

6262
def hook_path(name: str, git_dir: PathLike) -> str:
63-
""":return: path to the given named hook in the given git repository directory"""
63+
""":return: path to the given named hook in the given git repository directory
64+
65+
Note: This function does not respect the core.hooksPath configuration.
66+
For commit hooks that should respect this config, use run_commit_hook() instead.
67+
"""
6468
return osp.join(git_dir, "hooks", name)
6569

6670

71+
def _get_hooks_dir(repo: "Repo") -> str:
72+
"""Get the hooks directory, respecting core.hooksPath configuration.
73+
74+
:param repo: The repository to get the hooks directory for.
75+
:return: Path to the hooks directory.
76+
77+
Per git-config documentation, core.hooksPath can be:
78+
- An absolute path: used as-is
79+
- A relative path: relative to the directory where hooks are run from
80+
(typically the working tree root for non-bare repos)
81+
- If not set: defaults to $GIT_DIR/hooks
82+
"""
83+
# Import here to avoid circular imports
84+
from git.repo.base import Repo
85+
86+
try:
87+
hooks_path = repo.config_reader().get_value("core", "hooksPath")
88+
except Exception:
89+
# Config key not found or other error - use default
90+
hooks_path = None
91+
92+
if hooks_path:
93+
hooks_path = str(hooks_path)
94+
if osp.isabs(hooks_path):
95+
return hooks_path
96+
else:
97+
# Relative paths are relative to the working tree (or git_dir for bare repos)
98+
base_dir = repo.working_tree_dir if repo.working_tree_dir else repo.git_dir
99+
return osp.normpath(osp.join(base_dir, hooks_path))
100+
else:
101+
# Default: $GIT_DIR/hooks
102+
return osp.join(repo.git_dir, "hooks")
103+
104+
67105
def _has_file_extension(path: str) -> str:
68106
return osp.splitext(path)[1]
69107

@@ -82,7 +120,8 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None:
82120
83121
:raise git.exc.HookExecutionError:
84122
"""
85-
hp = hook_path(name, index.repo.git_dir)
123+
hooks_dir = _get_hooks_dir(index.repo)
124+
hp = osp.join(hooks_dir, name)
86125
if not os.access(hp, os.X_OK):
87126
return
88127

test/test_index.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,86 @@ def test_run_commit_hook(self, rw_repo):
10551055
output = Path(rw_repo.git_dir, "output.txt").read_text(encoding="utf-8")
10561056
self.assertEqual(output, "ran fake hook\n")
10571057

1058+
@pytest.mark.xfail(
1059+
type(_win_bash_status) is WinBashStatus.Absent,
1060+
reason="Can't run a hook on Windows without bash.exe.",
1061+
raises=HookExecutionError,
1062+
)
1063+
@pytest.mark.xfail(
1064+
type(_win_bash_status) is WinBashStatus.WslNoDistro,
1065+
reason="Currently uses the bash.exe of WSL, even with no WSL distro installed",
1066+
raises=HookExecutionError,
1067+
)
1068+
@with_rw_repo("HEAD", bare=False)
1069+
def test_run_commit_hook_respects_core_hookspath(self, rw_repo):
1070+
"""Test that run_commit_hook() respects core.hooksPath configuration.
1071+
1072+
This addresses issue #2083 where commit hooks were always looked for in
1073+
$GIT_DIR/hooks instead of respecting the core.hooksPath config setting.
1074+
"""
1075+
index = rw_repo.index
1076+
1077+
# Create a custom hooks directory outside of .git
1078+
custom_hooks_dir = Path(rw_repo.working_tree_dir) / "custom-hooks"
1079+
custom_hooks_dir.mkdir()
1080+
1081+
# Create a hook in the custom location
1082+
custom_hook = custom_hooks_dir / "fake-hook"
1083+
custom_hook.write_text(HOOKS_SHEBANG + "echo 'ran from custom hooks path' >output.txt")
1084+
custom_hook.chmod(0o744)
1085+
1086+
# Set core.hooksPath in the repo config
1087+
with rw_repo.config_writer() as config:
1088+
config.set_value("core", "hooksPath", str(custom_hooks_dir))
1089+
1090+
# Run the hook - it should use the custom path
1091+
run_commit_hook("fake-hook", index)
1092+
1093+
output_file = Path(rw_repo.working_tree_dir) / "output.txt"
1094+
self.assertTrue(output_file.exists(), "Hook should have created output.txt")
1095+
output = output_file.read_text(encoding="utf-8")
1096+
self.assertEqual(output, "ran from custom hooks path\n")
1097+
1098+
@pytest.mark.xfail(
1099+
type(_win_bash_status) is WinBashStatus.Absent,
1100+
reason="Can't run a hook on Windows without bash.exe.",
1101+
raises=HookExecutionError,
1102+
)
1103+
@pytest.mark.xfail(
1104+
type(_win_bash_status) is WinBashStatus.WslNoDistro,
1105+
reason="Currently uses the bash.exe of WSL, even with no WSL distro installed",
1106+
raises=HookExecutionError,
1107+
)
1108+
@with_rw_repo("HEAD", bare=False)
1109+
def test_run_commit_hook_respects_relative_core_hookspath(self, rw_repo):
1110+
"""Test that run_commit_hook() handles relative core.hooksPath correctly.
1111+
1112+
Per git-config docs, relative paths for core.hooksPath are relative to
1113+
the directory where hooks are run (typically the working tree root).
1114+
"""
1115+
index = rw_repo.index
1116+
1117+
# Create a custom hooks directory with a relative path
1118+
custom_hooks_dir = Path(rw_repo.working_tree_dir) / "relative-hooks"
1119+
custom_hooks_dir.mkdir()
1120+
1121+
# Create a hook in the custom location
1122+
custom_hook = custom_hooks_dir / "fake-hook"
1123+
custom_hook.write_text(HOOKS_SHEBANG + "echo 'ran from relative hooks path' >output.txt")
1124+
custom_hook.chmod(0o744)
1125+
1126+
# Set core.hooksPath to a relative path
1127+
with rw_repo.config_writer() as config:
1128+
config.set_value("core", "hooksPath", "relative-hooks")
1129+
1130+
# Run the hook - it should resolve the relative path correctly
1131+
run_commit_hook("fake-hook", index)
1132+
1133+
output_file = Path(rw_repo.working_tree_dir) / "output.txt"
1134+
self.assertTrue(output_file.exists(), "Hook should have created output.txt")
1135+
output = output_file.read_text(encoding="utf-8")
1136+
self.assertEqual(output, "ran from relative hooks path\n")
1137+
10581138
@ddt.data((False,), (True,))
10591139
@with_rw_directory
10601140
def test_hook_uses_shell_not_from_cwd(self, rw_dir, case):

0 commit comments

Comments
 (0)