Introduction
Git hooks are executable scripts that run automatically at specific points in the Git lifecycle. While many developers associate them solely with code formatting and linting, their potential extends far beyond—into workflow automation, project management, and team collaboration. This article explores how to leverage Git hooks for comprehensive automation strategies.
Understanding Git Hooks
Git hooks reside in the .git/hooks/ directory of every repository. They are standard executable scripts written in any language—bash, Python, Node.js, or Ruby. Hooks fall into two categories: client-side hooks (pre-commit, pre-push, commit-msg) that run on a developer’s machine, and server-side hooks (pre-receive, update, post-receive) that execute on the remote repository.
The hook lifecycle follows Git operations chronologically. During a commit, hooks execute in this order: pre-commit → commit-msg → post-commit. For a push, the sequence is pre-push (client) → pre-receive → update → post-receive (server).
Setting Up Hooks with Husky
Managing hooks manually by placing scripts in .git/hooks/ is impractical for teams because that directory is not tracked by Git. Husky solves this by allowing hooks to be configured within the repository and shared across the team.
Installation with Husky v9+ is straightforward:
npx husky init
This creates a .husky/ directory where hook scripts are plain text files:
# .husky/pre-commit
npx lint-staged
For CI environments, hooks can be skipped by setting HUSKY=0:
HUSKY=0 git push
Integrating lint-staged
Lint-staged runs linters only on staged files, keeping pre-commit checks fast:
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{css,scss}": ["stylelint --fix", "prettier --write"]
}
}
Pre-Commit Hooks: The First Line of Defense
Pre-commit hooks execute before each commit and are ideal for fast, localized checks.
| Check Type | Tools | Purpose |
|---|---|---|
| Linting | ESLint, stylelint | Enforce code style standards |
| Formatting | Prettier, dprint | Auto-format code consistently |
| Type checking | TypeScript --noEmit | Catch type errors early |
| Secret detection | secretlint, truffleHog | Prevent credential leaks |
| File validation | Custom scripts | Block large files or TODO commits |
A comprehensive pre-commit configuration:
# .husky/pre-commit
npx lint-staged
npx tsc --noEmit
npx secretlint "**/*"
Performance is critical—pre-commit hooks should complete in under five seconds to avoid disrupting the development flow.
Pre-Push Hooks: The CI Safety Net
Pre-push hooks run before code reaches the remote repository, making them ideal for longer-running validations:
# .husky/pre-push
npm test
npm run build
npm audit
Branch naming validation ensures consistency across the team:
#!/bin/sh
branch=$(git rev-parse --abbrev-ref HEAD)
if ! echo "$branch" | grep -qE '^(feature|bugfix|hotfix)/[A-Z]+-[0-9]+-'; then
echo "Error: Branch name must match feature/ABC-123-description"
exit 1
fi
Commit-Message Hooks with commitlint
Enforcing conventional commit messages ensures a consistent, parseable history. Using commitlint with Husky:
# .husky/commit-msg
npx --no -- commitlint --edit $1
Configure commitlint in commitlint.config.js:
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'subject-max-length': [2, 'always', 50],
'body-max-line-length': [2, 'always', 72],
},
};
Valid commit messages follow the <type>(<scope>): <description> format:
feat(auth): implement OAuth 2.0 refresh token rotation
fix(api): handle null pointer in user deserialization
docs(readme): update installation instructions
Post-Commit and Post-Merge Hooks
Post-merge hooks are particularly useful for dependency management. Automatically install updated dependencies when lock files change:
# .husky/post-merge
changed=$(git diff HEAD~1 --name-only)
if echo "$changed" | grep -q "pnpm-lock.yaml"; then
pnpm install
fi
CI Equivalence Strategy
The most robust pattern ensures hooks and CI run identical checks. Extract shared scripts that both environments call:
#!/bin/sh
# scripts/validate.sh - called by both hooks and CI
npm run lint
npm run typecheck
npm test
Local hooks should warn or auto-fix where possible, while CI enforces strictly. This creates a safety net: developers catch issues before push, and CI catches anything that slips through.
Conclusion
Git hooks are a powerful automation layer that extends far beyond code formatting. Combining Husky with lint-staged provides the recommended baseline for most projects. Pre-push hooks add a safety net before code reaches CI, and commitlint ensures a clean, parseable commit history. The ultimate goal is a seamless developer experience where automation handles repetitive checks, allowing teams to focus on logic and design.
