Git Hooks
Git hooks are automated scripts that run at specific points in your Git workflow. Think of them as gatekeepers or quality checkers that spring into action when you perform certain Git operations - committing code, pushing changes, or receiving updates.
The power of hooks lies in automation. Instead of manually running linters, formatters, or tests before every commit, hooks do it for you. They catch problems early, enforce standards, and save you from embarrassing mistakes like committing debug code or pushing broken tests.
Understanding Git Hooks
What Are Hooks?
Hooks are executable scripts stored in your repository’s .git/hooks/
directory. When specific Git events occur, Git looks for corresponding hook
scripts and executes them. If a hook script exits with a non-zero status
(indicating failure), Git aborts the operation.
Key characteristics:
- Event-driven: Triggered by specific Git actions
- Local: Stored in
.git/hooks/, not tracked by version control - Scriptable: Written in any language (bash, Python, Node.js, etc.)
- Blocking: Can prevent operations from completing
Client-Side vs Server-Side Hooks
Client-side hooks run on your local machine during your personal workflow:
- Before commits
- After commits
- Before pushes
- After checkouts
Server-side hooks run on remote repositories when receiving pushes:
- Before accepting commits
- After receiving commits
- During updates
This guide focuses on client-side hooks - the ones you’ll use daily to improve your personal workflow.
Essential Client-Side Hooks
Pre-Commit Hook: Quality Gate Before Saving
The pre-commit hook runs immediately before Git creates a commit. This is your
last chance to catch issues before they become part of your history.
Common use cases:
- Run code linters (ESLint, Pylint, RuboCop)
- Format code automatically (Prettier, Black, gofmt)
- Check for debug statements or console.log calls
- Verify file size limits
- Scan for sensitive data (API keys, passwords)
Example: Simple linting check
Create .git/hooks/pre-commit:
#!/bin/bash
echo "Running ESLint..."
# Run ESLint on staged JavaScript files
git diff --cached --name-only --diff-filter=ACM | grep '\.js$' | xargs eslint
# If linting fails, abort commit
if [ $? -ne 0 ]; then
echo "❌ ESLint found issues. Commit aborted."
echo "Fix the errors or use 'git commit --no-verify' to bypass."
exit 1
fi
echo "✅ Pre-commit checks passed"
exit 0Make it executable:
chmod +x .git/hooks/pre-commitHow it works:
- You run
git commit - Git stages your changes
- Pre-commit hook executes
- If hook succeeds (exit 0), commit proceeds
- If hook fails (exit 1), commit aborts
Pro Tip: Test your hook by making a commit with known linting errors. The hook should catch and prevent the commit.
Commit-Msg Hook: Enforce Message Standards
The commit-msg hook validates your commit message after you write it but
before the commit is finalized. Use it to enforce team standards like
Conventional Commits or ticket number requirements.
Common use cases:
- Enforce Conventional Commits format (feat:, fix:, docs:)
- Require ticket/issue numbers (JIRA-123, #456)
- Check message length and formatting
- Prevent generic messages (“wip”, “fix”, “update”)
Example: Enforce Conventional Commits
Create .git/hooks/commit-msg:
#!/bin/bash
commit_msg_file=$1
commit_msg=$(cat "$commit_msg_file")
# Conventional Commits pattern: type(scope): description
pattern="^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{10,}"
if ! echo "$commit_msg" | grep -qE "$pattern"; then
echo "❌ Commit message format invalid"
echo ""
echo "Expected format: type(scope): description"
echo "Types: feat, fix, docs, style, refactor, test, chore"
echo ""
echo "Examples:"
echo " feat(auth): add OAuth login support"
echo " fix(api): handle null response from user endpoint"
echo " docs: update installation instructions"
echo ""
echo "Your message: $commit_msg"
exit 1
fi
echo "✅ Commit message format valid"
exit 0Make it executable:
chmod +x .git/hooks/commit-msgConventional Commits format:
<type>(<optional scope>): <description>
[optional body]
[optional footer]Example messages:
feat(auth): implement two-factor authentication
fix(payment): resolve timeout on large transactions
docs: add API rate limiting documentation
refactor(database): optimize query performance
test(user): add integration tests for registration flowPre-Push Hook: Final Verification Before Sharing
The pre-push hook runs after you initiate git push but before changes are
sent to the remote. This is your safety net before sharing code with the team.
Common use cases:
- Run test suites to ensure nothing is broken
- Prevent pushing to protected branches directly
- Check for TODOs or FIXMEs in critical files
- Verify build succeeds
- Check that dependencies are up to date
Example: Run tests before pushing
Create .git/hooks/pre-push:
#!/bin/bash
echo "Running test suite before push..."
# Run your project's tests
npm test
# Check test exit status
if [ $? -ne 0 ]; then
echo "❌ Tests failed. Push aborted."
echo "Fix failing tests or use 'git push --no-verify' to bypass."
exit 1
fi
echo "✅ All tests passed. Proceeding with push."
exit 0Make it executable:
chmod +x .git/hooks/pre-pushPerformance consideration: Running full test suites on every push can be slow. Consider:
- Running only fast unit tests (skip integration/E2E tests)
- Using watch mode during development
- Relying on CI/CD for comprehensive testing
- Caching test results
Practical Implementation Strategies
Starting Simple
Don’t try to implement every hook at once. Start with one that addresses your most common problem.
Recommended starting point:
If you often commit code with linting errors → Start with pre-commit
If your team struggles with inconsistent commit messages → Start with
commit-msg
If you’ve pushed broken code that failed CI → Start with pre-push
Bypassing Hooks When Necessary
Sometimes you need to commit despite hook failures - perhaps you’re making a deliberate exception or working on a fix for the hook itself.
Bypass pre-commit and commit-msg hooks:
git commit --no-verify -m "Emergency hotfix"
# or shorthand
git commit -n -m "Emergency hotfix"Bypass pre-push hook:
git push --no-verify
# or shorthand
git push -nWarning: Use --no-verify sparingly. These hooks exist to prevent problems.
Bypassing them should be a conscious decision, not a habit.
Sharing Hooks With Your Team
Here’s a challenge: hooks live in .git/hooks/, which isn’t tracked by Git
(it’s in .gitignore by default). How do you share hooks with your team?
Solution 1: Commit hooks to a tracked directory
# Create hooks directory in your repository
mkdir -p scripts/git-hooks
# Move your hooks there
mv .git/hooks/pre-commit scripts/git-hooks/
mv .git/hooks/commit-msg scripts/git-hooks/
# Create symbolic links
ln -s ../../scripts/git-hooks/pre-commit .git/hooks/pre-commit
ln -s ../../scripts/git-hooks/commit-msg .git/hooks/commit-msg
# Add setup script for new developers
cat > scripts/install-hooks.sh << 'EOF'
#!/bin/bash
cd .git/hooks
ln -sf ../../scripts/git-hooks/pre-commit pre-commit
ln -sf ../../scripts/git-hooks/commit-msg commit-msg
chmod +x pre-commit commit-msg
echo "Git hooks installed successfully"
EOF
chmod +x scripts/install-hooks.shNow your hooks are version controlled, and teammates run
./scripts/install-hooks.sh after cloning.
Solution 2: Use a hook manager (Recommended)
Tools like Husky (Node.js) or pre-commit (Python) manage hooks automatically:
Husky example:
# Install Husky
npm install --save-dev husky
# Initialize Husky
npx husky init
# Add pre-commit hook
echo "npm run lint" > .husky/pre-commit
# Add commit-msg hook
echo "npx commitlint --edit $1" > .husky/commit-msgHusky automatically installs hooks when teammates run npm install. Hooks are
tracked in your repository under .husky/.
Solution 3: Git config (Git 2.9+)
Configure Git to look for hooks in a tracked directory:
# Set hooks directory
git config core.hooksPath scripts/git-hooks
# Now Git uses scripts/git-hooks/ instead of .git/hooks/Add this to your repository’s setup instructions.
Hook Management Tools
Husky (Node.js Projects)
Best for: JavaScript/TypeScript projects
Setup:
npm install --save-dev husky
npx husky init
echo "npm run lint-staged" > .husky/pre-commitBenefits:
- Automatic installation on
npm install - Zero configuration for teammates
- Integrated with package.json scripts
Pre-commit Framework (Language-Agnostic)
Best for: Multi-language projects or Python projects
Setup:
# Install pre-commit
pip install pre-commit
# Create .pre-commit-config.yaml
cat > .pre-commit-config.yaml << 'EOF'
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
EOF
# Install hooks
pre-commit installBenefits:
- Language-agnostic (supports Python, JS, Go, Rust, etc.)
- Rich plugin ecosystem
- Runs hooks in isolated environments
- Caches results for speed
Lint-Staged (Node.js)
Best for: Running linters only on staged files (fast)
Setup:
npm install --save-dev lint-staged
# In package.json
{
"lint-staged": {
"*.js": ["eslint --fix", "git add"],
"*.css": ["stylelint --fix", "git add"],
"*.{json,md}": ["prettier --write", "git add"]
}
}Benefits:
- Only lints changed files (extremely fast)
- Can automatically fix and re-stage files
- Integrates with Husky for hook management
Common Hook Patterns
Pattern 1: Branch Protection
Prevent accidental commits to protected branches:
#!/bin/bash
# pre-commit
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then
echo "❌ Direct commits to $branch are not allowed"
echo "Create a feature branch: git checkout -b feature/your-feature"
exit 1
fi
exit 0Pattern 2: Dependency Check
Warn if package-lock.json changed but package.json didn’t:
#!/bin/bash
# pre-commit
if git diff --cached --name-only | grep -q "package-lock.json"; then
if ! git diff --cached --name-only | grep -q "package.json"; then
echo "⚠️ Warning: package-lock.json changed but package.json didn't"
echo "Did you mean to update dependencies?"
# Don't exit 1 - just warn
fi
fi
exit 0Pattern 3: Code Formatting
Automatically format code and re-stage:
#!/bin/bash
# pre-commit
echo "Formatting code..."
# Format staged files
git diff --cached --name-only --diff-filter=ACM |
grep '\.js$' |
xargs prettier --write
# Re-stage formatted files
git diff --cached --name-only --diff-filter=ACM |
grep '\.js$' |
xargs git add
echo "✅ Code formatted"
exit 0Debugging Hooks
Hook Not Running?
Check execution permissions:
ls -la .git/hooks/
# Should show -rwxr-xr-x (executable)Fix permissions:
chmod +x .git/hooks/pre-commitHook Silently Failing?
Add debug output to your hook:
#!/bin/bash
set -x # Print each command before executing
echo "Hook starting..."
# Your hook logic here
echo "Hook finished"Run git commit to see detailed output.
Testing Hooks Manually
Run hooks directly without committing:
# Test pre-commit hook
.git/hooks/pre-commit
# Test commit-msg hook with sample message
echo "fix: sample message" > /tmp/test-msg
.git/hooks/commit-msg /tmp/test-msgBest Practices
Keep Hooks Fast
Slow hooks disrupt workflow. If a hook takes more than a few seconds, developers will bypass it.
Optimization strategies:
- Run linters only on changed files (use
lint-staged) - Cache results when possible
- Skip comprehensive tests in pre-commit (use pre-push instead)
- Run heavy operations in CI/CD, not locally
Speed benchmark:
- Pre-commit: < 5 seconds
- Commit-msg: < 1 second
- Pre-push: < 30 seconds
Fail Fast and Clear
When a hook fails, provide:
- Clear error message explaining what failed
- Instructions on how to fix it
- Bypass option if needed (mention
--no-verify)
Bad error:
Hook failedGood error:
❌ Pre-commit hook failed: ESLint errors detected
Errors in:
src/components/Button.js:23 - 'onClick' is missing in props validation
src/utils/api.js:45 - Unexpected console statement
Fix these errors and commit again, or bypass with:
git commit --no-verifyDocument Your Hooks
Add a README in scripts/git-hooks/ explaining:
- What each hook does
- Why it exists
- How to bypass it when necessary
- How to update or disable it
Example README:
# Git Hooks
## Pre-commit
Runs ESLint on staged JavaScript files. Prevents commits with linting errors.
**Bypass:** `git commit --no-verify`
## Commit-msg
Enforces Conventional Commits format. See https://conventionalcommits.org
**Bypass:** `git commit --no-verify`
## Pre-push
Runs unit tests before pushing. Prevents pushing broken code.
**Bypass:** `git push --no-verify`Version Control Your Hooks
Store hooks in a tracked directory (not .git/hooks/) so they’re:
- Backed up
- Shared with teammates
- Versioned alongside your code
- Reviewable in pull requests
When Not to Use Hooks
Hooks aren’t always the right solution:
Don’t use hooks for:
- Heavy operations that should run in CI/CD (full test suites, builds)
- Mandatory checks that can’t be bypassed (use CI/CD for enforcement)
- Cross-platform compatibility issues (hooks may fail on Windows vs Unix)
- Operations requiring network access (flaky, slow, dependency on external services)
Do use hooks for:
- Quick local validation (linting, formatting)
- Workflow automation (ticket number validation, commit format)
- Developer assistance (catching obvious mistakes early)
- Personal productivity (tools that help you work faster)
Getting Started Checklist
Ready to add hooks to your workflow?
- Identify your pain point - What mistake do you make repeatedly?
- Start with one hook - Don’t implement everything at once
- Keep it simple - A basic hook is better than no hook
- Test thoroughly - Make sure it works before relying on it
- Document usage - Help teammates understand what’s happening
- Use a hook manager - Husky or pre-commit for easier sharing
- Iterate gradually - Add complexity as needs evolve
What’s Next?
You now understand the fundamentals of Git hooks and how to implement common patterns. Hooks are powerful automation tools that catch mistakes early and enforce standards effortlessly.
To deepen your understanding:
- Explore the full list of available hooks in
.git/hooks/*.sample - Check out Husky or pre-commit framework documentation
- Review your team’s workflow for automation opportunities
- Experiment with different hook combinations
Remember: Hooks are helpers, not enforcers. They should make your workflow
smoother, not create friction. Start small, iterate based on real needs, and
always provide escape hatches (--no-verify) for exceptional cases.
The best hooks are the ones you forget are running - they just work, silently protecting you from common mistakes.