Git Hooks and Automation: Automatic Quality Gates
Git hooks are scripts that Git automatically executes in response to events (commit, push, merge). They enable automating checks, linting, testing, and validations, creating quality gates that prevent issues before they reach the remote repository. With tools like Husky and lint-staged, configuring hooks becomes simple and effective.
🎯 What You'll Learn
- What Git hooks are and when they execute
- Pre-commit hook for linting and formatting
- Commit-msg hook for conventional commits
- Husky and lint-staged for easy hook management
What Are Git Hooks
Git hooks are scripts saved in .git/hooks/ that Git automatically executes
at specific moments in the workflow. They can be written in any executable language
(bash, Python, Node.js, etc.).
📋 Main Hooks
- pre-commit: Before creating a commit (linting, tests)
- prepare-commit-msg: Prepare commit message (template)
- commit-msg: Validate commit message (conventional commits)
- post-commit: After commit created (notifications)
- pre-push: Before push (complete test suite)
- pre-rebase: Before rebase
Pre-Commit Hook: Linting and Formatting
The pre-commit hook is the most used. It verifies code before committing:
#!/bin/bash
# Run ESLint on staged files
npm run lint
if [ $? -ne 0 ]; then
echo "❌ Linting failed. Fix errors before committing."
exit 1
fi
# Run tests
npm test
if [ $? -ne 0 ]; then
echo "❌ Tests failed. Fix tests before committing."
exit 1
fi
echo "✅ All checks passed!"
exit 0
chmod +x .git/hooks/pre-commit
Problem: Hooks Not Shared
Hooks in .git/hooks/ are not committed (`.git` is gitignored).
Each developer must configure them manually. Solution: Husky.
Husky: Simplified Hook Management
Husky allows defining hooks in package.json and sharing them
with the team via Git.
# Install Husky
npm install --save-dev husky
# Initialize Husky
npx husky init
# Creates .husky/ directory with shareable hooks
# Create pre-commit hook
npx husky add .husky/pre-commit "npm run lint && npm test"
# File created: .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run lint && npm test
{{ '{' }}
"scripts": {{ '{' }}
"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write .",
"test": "jest",
"prepare": "husky install"
{{ '}' }},
"devDependencies": {{ '{' }}
"husky": "^8.0.0"
{{ '}' }}
{{ '}' }}
Lint-Staged: Lint Only Modified Files
lint-staged runs linting/formatting only on staged files, not the entire project. Much faster!
npm install --save-dev lint-staged
{{ '{' }}
"lint-staged": {{ '{' }}
"*.{{ '{' }}ts,tsx{{ '}' }}": [
"eslint --fix",
"prettier --write"
],
"*.{{ '{' }}js,jsx{{ '}' }}": [
"eslint --fix",
"prettier --write"
],
"*.css": [
"prettier --write"
]
{{ '}' }},
"scripts": {{ '{' }}
"prepare": "husky install"
{{ '}' }}
{{ '}' }}
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
Commit-Msg Hook: Conventional Commits
Enforce conventional commits (feat:, fix:, docs:, etc.) for automatic changelogs:
npm install --save-dev @commitlint/cli @commitlint/config-conventional
module.exports = {{ '{' }}
extends: ['@commitlint/config-conventional'],
rules: {{ '{' }}
'type-enum': [2, 'always', [
'feat', // New feature
'fix', // Bug fix
'docs', // Documentation
'style', // Formatting
'refactor', // Refactoring
'test', // Tests
'chore' // Maintenance
]]
{{ '}' }}
{{ '}' }};
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no-install commitlint --edit "$1"
# ✅ Valid
git commit -m "feat: add user authentication"
git commit -m "fix: resolve payment bug"
# ❌ Invalid (missing type)
git commit -m "add feature"
# ⧗ input: add feature
# ✖ subject may not be empty [subject-empty]
# ✖ type may not be empty [type-empty]
Pre-Push Hook: Complete Test Suite
Run complete tests before push to prevent broken builds on CI:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Run complete test suite
npm test
# Verify production build
npm run build
# If one fails, push is blocked
exit $?
Complete Example: Professional Setup
Complete configuration with Husky, lint-staged, commitlint, and pre-push tests:
{{ '{' }}
"name": "my-project",
"scripts": {{ '{' }}
"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write .",
"test": "jest",
"test:ci": "jest --coverage",
"build": "tsc && vite build",
"prepare": "husky install"
{{ '}' }},
"lint-staged": {{ '{' }}
"*.{{ '{' }}ts,tsx{{ '}' }}": [
"eslint --fix",
"prettier --write",
"jest --bail --findRelatedTests"
],
"*.{{ '{' }}css,scss{{ '}' }}": [
"prettier --write"
]
{{ '}' }},
"devDependencies": {{ '{' }}
"husky": "^8.0.0",
"lint-staged": "^13.0.0",
"@commitlint/cli": "^17.0.0",
"@commitlint/config-conventional": "^17.0.0",
"eslint": "^8.0.0",
"prettier": "^2.8.0",
"jest": "^29.0.0"
{{ '}' }}
{{ '}' }}
my-project/
├── .husky/
│ ├── pre-commit # Lint-staged
│ ├── commit-msg # Commitlint
│ └── pre-push # Full tests + build
├── commitlint.config.js
├── .eslintrc.js
├── .prettierrc
├── package.json
└── src/
Bypass Hooks (Use With Caution)
Sometimes you need to skip hooks (e.g., WIP commits):
# Bypass all hooks
git commit --no-verify -m "WIP: incomplete feature"
git push --no-verify
# Abbreviation
git commit -n -m "WIP"
# ⚠️ Use only when absolutely necessary!
CI/CD Integration
Local hooks aren't enough (they can be bypassed). Reproduce the same checks on CI:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
# Same checks as local hooks
- run: npm ci
- run: npm run lint
- run: npm run format -- --check
- run: npm run test:ci
- run: npm run build
# Verify conventional commits
- uses: wagoid/commitlint-github-action@v5
Best Practices
✅ Do's
- Use Husky to share hooks with the team
- Use lint-staged for speed (only staged files)
- Verify commits with commitlint
- Keep hooks fast (< 10 seconds)
- Reproduce checks on CI/CD
- Document hook setup in README
❌ Don'ts
- Don't make hooks too slow (frustrates developers)
- Don't rely only on hooks (they can be bypassed)
- Don't run complete builds in pre-commit (use pre-push)
- Don't forget
"prepare": "husky install"in package.json
Advanced Hooks
#!/usr/bin/env sh
# .husky/prepare-commit-msg
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
# If it's a new commit (not merge/amend)
if [ -z "$COMMIT_SOURCE" ]; then
# Add template with branch name
BRANCH_NAME=$(git symbolic-ref --short HEAD)
echo "[$BRANCH_NAME] " > "$COMMIT_MSG_FILE"
fi
#!/usr/bin/env sh
# .husky/post-commit
COMMIT_MSG=$(git log -1 --pretty=%B)
AUTHOR=$(git log -1 --pretty=%an)
# Send notification to Slack
curl -X POST https://hooks.slack.com/services/YOUR/WEBHOOK/URL \
-H 'Content-Type: application/json' \
-d "{{ '{' }}\"text\":\"New commit by $AUTHOR: $COMMIT_MSG\"{{ '}' }}"
Troubleshooting
# Hook not executable
chmod +x .husky/pre-commit
# Husky not installed
npm run prepare
# Hook executed twice
# Remove duplicate hooks in .git/hooks/
# Lint-staged doesn't find files
# Verify pattern in package.json
# Commitlint doesn't work
# Check commitlint.config.js exists
Conclusion
Git hooks automate quality gates, preventing broken code, failed tests, and poorly formatted commits. Husky makes hooks easily shareable, lint-staged makes them fast, and commitlint ensures consistent messages. Combined with CI/CD, they create a robust workflow that maintains high code quality.
🎯 Key Points
- Git hooks automate pre-commit and pre-push checks
- Husky shares hooks via Git (no manual configuration)
- lint-staged runs linting only on modified files
- commitlint enforces conventional commits for changelog
- Reproduce checks on CI/CD for safety







