Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions git/index/fun.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]

Expand All @@ -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

Expand Down
122 changes: 122 additions & 0 deletions test/test_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading