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 0

Make it executable:

chmod +x .git/hooks/pre-commit

How it works:

  1. You run git commit
  2. Git stages your changes
  3. Pre-commit hook executes
  4. If hook succeeds (exit 0), commit proceeds
  5. 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 0

Make it executable:

chmod +x .git/hooks/commit-msg

Conventional 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 flow

Pre-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 0

Make it executable:

chmod +x .git/hooks/pre-push

Performance 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 -n

Warning: 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.sh

Now 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-msg

Husky 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-commit

Benefits:

  • 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 install

Benefits:

  • 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 0

Pattern 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 0

Pattern 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 0

Debugging Hooks

Hook Not Running?

Check execution permissions:

ls -la .git/hooks/
# Should show -rwxr-xr-x (executable)

Fix permissions:

chmod +x .git/hooks/pre-commit

Hook 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-msg

Best 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:

  1. Clear error message explaining what failed
  2. Instructions on how to fix it
  3. Bypass option if needed (mention --no-verify)

Bad error:

Hook failed

Good 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-verify

Document 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?

  1. Identify your pain point - What mistake do you make repeatedly?
  2. Start with one hook - Don’t implement everything at once
  3. Keep it simple - A basic hook is better than no hook
  4. Test thoroughly - Make sure it works before relying on it
  5. Document usage - Help teammates understand what’s happening
  6. Use a hook manager - Husky or pre-commit for easier sharing
  7. 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.