This post is part of our Git workflow series. For the complete branching strategy and team workflow, see Git Branching Strategy: A Practical Case.
Why Use Git Hooks?
You commit a console.log. CI fails. You forgot tests. Your message says “fix stuff.” Your team lead gives you that look.
Git hooks stop that. They’re scripts that run at specific points in your workflow — before commits, before pushes, after merges. They catch mistakes before anyone sees them.
What hooks prevent:
-
Bad code hitting the repo
- Linting errors, unused imports, formatting issues
- Failing tests
- Hardcoded secrets, vulnerable dependencies
- Syntax errors, type errors
-
Messy commit history
- Invalid message formats (
fix stuff→fix: resolve login timeout) - Missing issue references (enforces
feat: add dashboard (#123)) - Direct commits to protected branches
- Invalid message formats (
-
Wasted CI/CD minutes
- Issues caught locally, CI never runs on broken code
- Feedback in seconds, not minutes
-
Team inconsistency
- Same checks run for everyone, automatically
- No “works on my machine” excuses
- Standards enforced without nagging
The hooks we actually use:
| Hook | When It Runs | What It Checks | Skip It? |
|---|---|---|---|
pre-commit | Before commit is saved | Linting, formatting, tests | git commit --no-verify |
commit-msg | After commit message written | Message format, length | git commit --no-verify |
pre-push | Before pushing to remote | Full test suite, build | git push --no-verify |
Pro tip: You can skip hooks with --no-verify, but only when you know what you’re doing. Like fixing a docs typo when you don’t want the full test suite.
Pre-commit Hook
Checks code quality before commits:
#!/bin/bash
# .git/hooks/pre-commit
echo "Running pre-commit checks..."
# 1. Lint code
npm run lint
if [ $? -ne 0 ]; then
echo "❌ Linting failed. Please fix errors before committing."
exit 1
fi
# 2. Run tests
npm test
if [ $? -ne 0 ]; then
echo "❌ Tests failed. Please fix failing tests before committing."
exit 1
fi
# 3. Check commit message format (if using commitlint)
npm run commitlint --edit $1
if [ $? -ne 0 ]; then
echo "❌ Commit message doesn't follow convention."
exit 1
fi
echo "✅ Pre-commit checks passed!"
exit 0
Commit Message Hook
Validates commit message format:
#!/bin/bash
# .git/hooks/commit-msg
commit_msg=$(cat "$1")
# Check format: <type>(<scope>): <subject>
if ! echo "$commit_msg" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|chore|ci|build)(\(.+\))?: .{1,50}"; then
echo "❌ Invalid commit message format!"
echo ""
echo "Format: <type>(<scope>): <subject>"
echo ""
echo "Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build"
echo ""
echo "Example: feat(auth): add JWT authentication"
exit 1
fi
echo "✅ Commit message format is valid!"
exit 0
Using Husky for Git Hooks
Manual hooks in .git/hooks/ work but aren’t shareable. Husky fixes that.
# Install husky
npm install --save-dev husky
# Initialize husky
npx husky install
# Add pre-commit hook
npx husky add .husky/pre-commit "npm test"
# Add commit-msg hook
npx husky add .husky/commit-msg "npx commitlint --edit $1"
Why Husky?
- Shareable -
.git/hooks/isn’t tracked by Git;.husky/is - Automatic - New team members get hooks via
npm install - Cross-platform - Works on Windows, Mac, and Linux
- Simple - Just add scripts, no bash expertise needed
Add this to your package.json to install hooks automatically:
{
"scripts": {
"prepare": "husky install"
}
}Now npm install sets up hooks for every developer automatically.
Integrating Hooks with CI/CD
Hooks catch issues locally (fast). CI/CD catches anything that slips through (safety net). Use both.
The strategy:
- Pre-commit hook → Quick checks (linting, fast unit tests)
- Pre-push hook → Slower checks (full test suite)
- CI/CD pipeline → Everything + deployment
Example GitLab CI/CD pipeline:
# .gitlab-ci.yml
stages:
- lint
- test
- build
- deploy
# Run on all branches
lint:
stage: lint
script:
- npm run lint
only:
- branches
# Run on all branches
test:
stage: test
script:
- npm test
only:
- branches
# Build on all branches
build:
stage: build
script:
- npm run build
artifacts:
paths:
- dist/
only:
- branches
# Deploy develop → dev environment
deploy_dev:
stage: deploy
script:
- npm run deploy:dev
environment:
name: development
url: https://dev.example.com
only:
- develop
# Deploy staging → staging environment
deploy_staging:
stage: deploy
script:
- npm run deploy:staging
environment:
name: staging
url: https://staging.example.com
only:
- staging
# Deploy main → production (manual)
deploy_production:
stage: deploy
script:
- npm run deploy:prod
environment:
name: production
url: https://example.com
only:
- main
when: manual
The workflow:
- Developer makes changes
- Pre-commit hook catches lint errors in 5 seconds
- Fix, commit again
- Pre-push hook catches failing tests in 30 seconds
- Push to remote
- CI/CD pipeline runs full validation + deployment
Your CI should still run lint and tests (safety net), but make local checks faster by only running changed files:
# Pre-commit: Only lint changed files (fast)
npx lint-staged
# CI/CD: Lint entire codebase (thorough)
npm run lint Common Hook Patterns
1. Prevent Commits to Protected Branches
Block direct commits to main:
#!/bin/bash
# .git/hooks/pre-commit
branch=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,')
if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then
echo "❌ Direct commits to $branch are not allowed!"
echo "Create a feature branch instead:"
echo " git checkout -b feature/my-feature"
exit 1
fi
2. Check for Hardcoded Secrets
Catch API keys and passwords before commit:
#!/bin/bash
# .git/hooks/pre-commit
# Check for common secret patterns
if git diff --cached | grep -qE '(api_key|API_KEY|password|PASSWORD|secret|SECRET)\s*='; then
echo "❌ Possible hardcoded secret detected!"
echo "Use environment variables instead."
exit 1
fi
3. Auto-Format Code Before Commit
Run Prettier on staged files:
#!/bin/bash
# .git/hooks/pre-commit
# Format staged files
npx prettier --write $(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx|css|md)$')
# Re-stage formatted files
git add $(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx|css|md)$')
4. Require Issue Number in Commit Message
Force commits to reference an issue:
#!/bin/bash
# .git/hooks/commit-msg
commit_msg=$(cat "$1")
# Check for issue number (#123)
if ! echo "$commit_msg" | grep -qE '\(#[0-9]+\)'; then
echo "❌ Commit message must reference an issue number!"
echo "Example: feat: add user login (#123)"
exit 1
fi
Troubleshooting Hooks
Hook Not Running
Problem: Hook exists but doesn’t execute.
Solutions:
- Make it executable:
chmod +x .git/hooks/pre-commit - Check shebang:
#!/bin/bashmust be the first line - Check the name:
pre-commitnotpre-commit.sh
Hook Runs But Always Fails
Problem: Script has errors.
Debug:
# Run hook manually to see errors
bash .git/hooks/pre-commit
# Add debug output
echo "Debug: Current directory is $(pwd)"
echo "Debug: Git status:"
git status
Need to Skip Hook Temporarily
Solution:
# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "fix: emergency hotfix"
# Skip pre-push hook
git push --no-verify
Only use --no-verify when absolutely necessary. You’re bypassing safety checks.
Hooks Work Locally But Not for Team
Problem: .git/hooks/ isn’t tracked by Git.
Solution: Use Husky (see Using Husky above). It stores hooks in .husky/ which IS tracked.
Conclusion
Git hooks are your first line of defense. Set them up once, stop debugging CI failures.
Quick setup checklist:
- Install Husky:
npm install --save-dev husky - Initialize hooks:
npx husky install - Add pre-commit linting:
npx husky add .husky/pre-commit "npm run lint" - Add commit-msg validation:
npx husky add .husky/commit-msg "npx commitlint --edit $1" - Add auto-install:
"prepare": "husky install"in package.json - Test: make a commit and verify hooks run
- Commit
.husky/to share with the team
Catch mistakes locally before they hit review or break CI.
Hands-On Implementation Guide
Want to see this in a real project? The practical guide walks through Husky, lint-staged, and commitlint in an actual Astro blog:
Implementing Git Hooks with Husky: A Real-World Example →
Covers:
- Step-by-step Husky installation with terminal output
- Commitlint config for conventional commits
- Lint-staged for faster pre-commit checks (6-10x speedup)
- GitLab CI/CD integration
- Real troubleshooting from issues we hit
- Before/after time savings metrics
This is what we run on blog.stack101 — battle-tested and production-ready.
Related reading:
- Git Branching Strategy: A Practical Case - Complete workflow guide this post is part of
- How to Fix: Accidentally Committed to Main Branch - Recover from common Git mistakes