What Are Git Hooks?
Git hooks are scripts that run automatically at specific points in the git workflow: before a commit, after a push, before a merge, etc. They live in .git/hooks/ and can be written in any language.
The most useful hook for daily development: pre-commit โ runs before every commit, can block the commit if it exits with a non-zero code.
The Quick Setup: Husky + lint-staged
Writing hooks manually is error-prone (they don't get committed with the repo). Husky makes hooks shareable:
npm install -D husky lint-staged
# Initialize husky
npx husky init
This creates a .husky/ directory and adds a prepare script to package.json.
Pre-commit Hook: Lint and Format Only Changed Files
Linting your entire codebase on every commit is slow. lint-staged runs linters only on staged files:
# .husky/pre-commit
npx lint-staged
// package.json
{
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css}": [
"prettier --write"
]
}
}
Now every commit auto-fixes lint issues and formats code. If ESLint finds errors it can't auto-fix, the commit is blocked.
Pre-commit Hook: Run Type Check
TypeScript type errors shouldn't reach commits:
# .husky/pre-commit
npx lint-staged
npx tsc --noEmit # type check without emitting files
This adds a few seconds per commit but catches type errors before they hit CI.
Commit Message Validation: commit-msg Hook
Enforce consistent commit message formats:
# .husky/commit-msg
npx --no -- commitlint --edit $1
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore']],
'subject-min-length': [2, 'always', 10],
}
}
npm install -D @commitlint/cli @commitlint/config-conventional
Now this will be rejected:
fix stuff
And this will pass:
fix: resolve null pointer in user authentication middleware
Pre-push Hook: Run Tests Before Pushing
Block pushes if tests fail:
# .husky/pre-push
npm test -- --passWithNoTests
This is heavier than pre-commit (test suite runs), so pre-push is a good compromise: fast feedback locally, full verification before it hits CI.
Writing a Custom Hook
Plain shell scripts work fine. Here's one that prevents committing secrets:
#!/bin/bash
# .husky/pre-commit โ check for common secret patterns
SECRETS_PATTERN="(password|secret|api_key|access_token)\s*=\s*['\"][^'\"]{8,}"
if git diff --cached | grep -iE "$SECRETS_PATTERN"; then
echo "โ Possible secrets detected in staged files. Commit blocked."
echo " Use environment variables or a secrets manager instead."
exit 1
fi
Skip Hooks When You Need To
Sometimes you need to bypass hooks (fixing a CI emergency, WIP commit):
git commit --no-verify -m "WIP: temporary debugging commit"
Use sparingly. The --no-verify flag skips all hooks.
Sharing Hooks with Your Team
The .husky/ directory commits to git. Everyone on the team gets the hooks automatically when they run npm install (via the prepare script).
// package.json
{
"scripts": {
"prepare": "husky"
}
}
Key Takeaways
- Git hooks live in
.git/hooks/but use Husky to make them shareable via git - lint-staged runs linters only on staged files โ fast pre-commit checks
- pre-commit: lint, format, type-check
- commit-msg: enforce conventional commit format with commitlint
- pre-push: run test suite before changes leave your machine
- Skip with
--no-verifyin emergencies only