Skip to content
Open
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
3 changes: 3 additions & 0 deletions changelog.d/20251114_husky_local_install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- Install `ggshield` hooks inside `.husky/` when the repository uses Husky-managed hooks so local installs work out of the box. (#1143)
63 changes: 62 additions & 1 deletion ggshield/cmd/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,76 @@ def get_global_hook_dir_path() -> Optional[Path]:
def install_local(hook_type: str, force: bool, append: bool) -> int:
"""Local pre-commit/pre-push hook installation."""
check_git_dir()
hook_dir_path = get_local_hook_dir_path()
return create_hook(
hook_dir_path=Path(".git/hooks"),
hook_dir_path=hook_dir_path,
force=force,
local_hook_support=False,
hook_type=hook_type,
append=append,
)


def get_local_hook_dir_path() -> Path:
"""
Return the directory where local hooks should be installed.

Respects git's core.hooksPath configuration while handling special cases:
- Husky-managed repositories: returns .husky instead of .husky/_ to avoid
overwriting Husky's shim scripts
- Custom hooks path: returns the configured path
- Default: returns .git/hooks

Returns:
Path object pointing to the appropriate hooks directory.
"""
hooks_path = get_local_hooks_path()

if hooks_path is None:
return Path(".git/hooks")

# Husky uses .husky/_ for shim scripts - install alongside, not inside
if is_husky_hooks_path(hooks_path):
return hooks_path.parent

return hooks_path


def get_local_hooks_path() -> Optional[Path]:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Having both get_local_hook_dir_path() and get_local_hooks_path() is confusing. Maybe name this one get_hooks_path_from_local_git_config() (a bit long, but unambiguous)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I renamed to get_git_local_hooks_path, wdyt ?

"""
Reads the 'core.hooksPath' configuration from git's local repository
config.

Returns:
Configured hooks path, or None if core.hooksPath is not set.

Note:
Does not validate that the returned path exists or is accessible.
"""
try:
out = git(
["config", "--local", "--get", "core.hooksPath"], ignore_git_config=False
)
# Strip whitespace and expand ~ to user home directory
return Path(click.format_filename(out)).expanduser()
except subprocess.CalledProcessError:
# core.hooksPath not configured in local repository
return None


def is_husky_hooks_path(path: Path) -> bool:
"""
Detect Husky-generated hooks directories.

Husky v6+ uses a .husky/_ directory for hook shim scripts.
This function identifies that pattern to avoid overwriting Husky's setup.

Args:
path: Path to check for Husky pattern.
"""
return path.name == "_" and path.parent.name == ".husky"


def create_hook(
hook_dir_path: Path,
force: bool,
Expand Down
21 changes: 21 additions & 0 deletions tests/functional/test_install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from pathlib import Path

from tests.functional.utils import run_ggshield
from tests.repository import Repository


def test_install_local_detects_husky(tmp_path: Path) -> None:
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would expect a functional test to use Husky for real. Is it doable?

I think if you replace the use of run_ggshield() with a call to install_local() then this code can become a unit test for install_local().

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

A functional test with husky feels a bit heavy as it would introduce the need for npm and husky. As you suggested, I moved to a unit test

repo = Repository.create(tmp_path)

husky_dir = repo.path / ".husky"
(husky_dir / "_").mkdir(parents=True)
repo.git("config", "core.hooksPath", ".husky/_")

run_ggshield("install", "-m", "local", "-t", "pre-commit", cwd=repo.path)

husky_hook = husky_dir / "pre-commit"
assert husky_hook.is_file()
assert 'ggshield secret scan pre-commit "$@"' in husky_hook.read_text()

default_hook = repo.path / ".git/hooks/pre-commit"
assert not default_hook.exists()
Loading