How do Git hooks enforce code quality gates, and how do you distribute them across a team using Husky or the pre-commit framework?
Quick Answer
Git hooks are executable scripts in .git/hooks/ triggered at events like pre-commit, commit-msg, and pre-push. They enforce linting, secret scanning, test execution, and commit message conventions. Since .git/hooks/ is not tracked by Git, tools like Husky (Node.js) and pre-commit (Python) install hooks from tracked config files, ensuring every team member runs the same checks.
Detailed Answer
Think of Git hooks like airport security checkpoints at different stages of travel. The pre-commit hook is the TSA screening before you enter the terminal: it checks for prohibited items (lint errors, secrets, formatting issues). The commit-msg hook is the boarding pass scanner: it verifies your documentation (commit message format) meets requirements. The pre-push hook is the customs check before your luggage leaves the country: it runs comprehensive tests before your code reaches the remote. If any checkpoint fails, your journey (git operation) is halted until you fix the issue.
Git hooks are executable scripts placed in .git/hooks/ that Git invokes at specific lifecycle points. Client-side hooks include: pre-commit (runs before the commit is created, ideal for linting and formatting), prepare-commit-msg (runs after the default message is generated but before the editor opens), commit-msg (validates the final commit message), post-commit (runs after the commit, useful for notifications), pre-push (runs before data is sent to the remote), pre-rebase (prevents rebasing certain branches), and post-checkout (useful for updating dependencies after switching branches). Server-side hooks include pre-receive, update, and post-receive, which run on the Git server and cannot be bypassed.
Internally, Git checks for executable files in .git/hooks/ matching the event name. The hook receives information through command-line arguments and stdin depending on the hook type: pre-commit receives no arguments, commit-msg receives the path to the temporary message file, and pre-receive gets old-ref new-ref branch-name on stdin for each pushed ref. Any hook exiting with non-zero status aborts the operation. Hooks can be written in any language with a proper shebang line. The critical limitation is that .git/hooks/ is inside the .git directory, which is not tracked by Git, so hooks are not shared when someone clones the repository. This is where distribution tools become essential.
In production, hook distribution tools solve the sharing problem. Husky (for Node.js projects) installs hooks from configuration in .husky/ directory, which is tracked in Git. Running npx husky init creates the directory and a sample pre-commit hook. The pre-commit framework (Python-based, language-agnostic) uses a .pre-commit-config.yaml file that defines hooks as downloadable repositories. It supports hooks from any language ecosystem and manages virtual environments automatically. Lefthook (Go-based) uses a lefthook.yml file and is significantly faster than Husky for large projects. Regardless of tool, the pattern is: define hooks in a tracked configuration file, install them via a project setup command (npm install, pre-commit install), and they run automatically. Best practice is to pair fast client-side hooks (lint-staged for checking only modified files) with comprehensive server-side CI checks that cannot be bypassed.
A critical gotcha: all client-side hooks can be bypassed with git commit --no-verify or git push --no-verify. This is intentional, as developers sometimes need to commit broken code (WIP commits before rebasing). Never rely solely on client-side hooks for security or compliance; always enforce critical checks in CI pipelines or server-side hooks. Another trap: hooks that are too slow destroy developer productivity and get bypassed habitually. Keep pre-commit hooks under 5 seconds by using lint-staged to only check staged files rather than the entire codebase. Also, hooks must handle edge cases like empty commits, merge commits, and partial staging to avoid false failures.