Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
43 changes: 41 additions & 2 deletions git/index/fun.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import statement from git.repo.base import Repo is only used for type hinting in the function signature on line 71. This import is already conditionally imported at the module level within the TYPE_CHECKING block (see lines 48-52), making this local import unnecessary. Consider removing this local import and relying on the string annotation "Repo" which is already being used.

Suggested change
from git.repo.base import Repo

Copilot uses AI. Check for mistakes.

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 +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

Expand Down
80 changes: 80 additions & 0 deletions test/test_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment on lines 1069 to 1096
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test only covers non-bare repositories (bare=False). Consider adding test coverage for bare repositories with core.hooksPath set, since the existing test_run_commit_hook test on line 1050 uses a bare repository. This is important because the logic in _get_hooks_dir() falls back to git_dir for bare repos when resolving relative paths (line 98 of git/index/fun.py).

Copilot uses AI. Check for mistakes.

@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")
Comment on lines 1109 to 1136
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test only covers non-bare repositories (bare=False). Consider adding test coverage for bare repositories with relative core.hooksPath, since the logic in _get_hooks_dir() falls back to git_dir for bare repos when resolving relative paths (line 98 of git/index/fun.py).

Copilot uses AI. Check for mistakes.

@ddt.data((False,), (True,))
@with_rw_directory
def test_hook_uses_shell_not_from_cwd(self, rw_dir, case):
Expand Down
Loading