From fd4022901d64d0403b61e0ac48241ac901d2872b Mon Sep 17 00:00:00 2001 From: Henri Hubert Date: Fri, 14 Nov 2025 18:48:06 +0100 Subject: [PATCH 1/2] feat(install): allow to install hook locally with husky Fixes #1143 --- changelog.d/20251114_husky_local_install.md | 3 ++ ggshield/cmd/install.py | 39 ++++++++++++++++++++- tests/functional/test_install.py | 21 +++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20251114_husky_local_install.md create mode 100644 tests/functional/test_install.py 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..145119a5ff 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,42 @@ 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. + + If core.hooksPath is configured, honor it and detect Husky-managed repositories + to avoid overwriting Husky's shim scripts. + """ + hooks_path = get_local_hooks_path() + if hooks_path is None: + return Path(".git/hooks") + + if is_husky_hooks_path(hooks_path): + return hooks_path.parent + + return hooks_path + + +def get_local_hooks_path() -> Optional[Path]: + """Return the hooks path defined in the repository config, if any.""" + try: + out = git( + ["config", "--local", "--get", "core.hooksPath"], ignore_git_config=False + ) + except subprocess.CalledProcessError: + return None + return Path(click.format_filename(out)).expanduser() + + +def is_husky_hooks_path(path: Path) -> bool: + """Detect Husky-generated hooks directories (.husky/_).""" + try: + return path.name == "_" and path.parent.name == ".husky" + except IndexError: + return False + + 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() From 4cfbc123e43a8d6b3b4679efba5f75239941570b Mon Sep 17 00:00:00 2001 From: Henri Hubert Date: Mon, 17 Nov 2025 14:57:56 +0100 Subject: [PATCH 2/2] chore: clean after review by improving docstrings and removing unecessary exceptions --- ggshield/cmd/install.py | 42 ++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/ggshield/cmd/install.py b/ggshield/cmd/install.py index 145119a5ff..d24103d6d6 100644 --- a/ggshield/cmd/install.py +++ b/ggshield/cmd/install.py @@ -117,13 +117,21 @@ def get_local_hook_dir_path() -> Path: """ Return the directory where local hooks should be installed. - If core.hooksPath is configured, honor it and detect Husky-managed repositories - to avoid overwriting Husky's shim scripts. + 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 @@ -131,22 +139,38 @@ def get_local_hook_dir_path() -> Path: def get_local_hooks_path() -> Optional[Path]: - """Return the hooks path defined in the repository config, if any.""" + """ + 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 - return Path(click.format_filename(out)).expanduser() def is_husky_hooks_path(path: Path) -> bool: - """Detect Husky-generated hooks directories (.husky/_).""" - try: - return path.name == "_" and path.parent.name == ".husky" - except IndexError: - return False + """ + 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(