diff --git a/changelog.d/20251114_husky_local_install.md b/changelog.d/20251114_husky_local_install.md new file mode 100644 index 0000000000..b2231288c6 --- /dev/null +++ b/changelog.d/20251114_husky_local_install.md @@ -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) diff --git a/ggshield/cmd/install.py b/ggshield/cmd/install.py index 3670b33f9b..d24103d6d6 100644 --- a/ggshield/cmd/install.py +++ b/ggshield/cmd/install.py @@ -103,8 +103,9 @@ 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, @@ -112,6 +113,66 @@ def install_local(hook_type: str, force: bool, append: bool) -> int: ) +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]: + """ + 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, diff --git a/tests/functional/test_install.py b/tests/functional/test_install.py new file mode 100644 index 0000000000..0160d216bb --- /dev/null +++ b/tests/functional/test_install.py @@ -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: + 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()