Git Hooks: pre-commit, pre-push, post-merge

Complete guide to Git hooks — all hook types explained, custom hook scripts, shared hooks with Husky and pre-commit framework, and production patterns for automation.

published: reading time: 10 min read updated: March 31, 2026

Introduction

Git hooks are scripts that run automatically at specific points in Git’s workflow. They’re the most powerful extensibility mechanism Git provides — enabling linting, testing, formatting, commit message validation, deployment triggers, and custom notifications without modifying Git itself.

Every Git repository has a .git/hooks/ directory containing sample hook scripts. When activated (by removing the .sample extension and making executable), these scripts intercept Git operations and can accept, reject, or modify them.

This comprehensive guide covers all Git hook types, practical examples for each, strategies for sharing hooks across teams, and production patterns that enforce code quality automatically.

When to Use / When Not to Use

When to use Git hooks:

  • Enforcing code quality standards before commits
  • Running tests before pushes
  • Validating commit message formats
  • Automating post-merge tasks (install dependencies, rebuild)
  • Preventing secrets from being committed

When not to use Git hooks:

  • For tasks that must run on the server (use CI/CD instead)
  • When hooks are slow (developers will bypass them)
  • For cross-repository enforcement (hooks aren’t versioned by default)

Core Concepts

Git hooks are categorized by when they execute:

Client-Side Hooks

graph TD
    CLIENT["Client-Side Hooks"] --> PRE["Before Local Actions"]
    CLIENT --> POST["After Local Actions"]

    PRE --> PC["pre-commit\n(before commit message)"]
    PRE --> PM["prepare-commit-msg\n(edit default message)"]
    PRE --> CM["commit-msg\n(after commit message)"]
    PRE --> PS["pre-push\n(before push)"]

    POST --> POM["post-merge\n(after merge/pull)"]
    POST --> POC["post-checkout\n(after checkout)"]
    POST --> POR["post-rewrite\n(after rebase/amend)"]

Server-Side Hooks

graph TD
    SERVER["Server-Side Hooks"] --> REMOTE["On Remote Repository"]

    REMOTE --> PA["pre-receive\n(before accepting push)"]
    REMOTE --> UP["update\n(per ref update)"]
    REMOTE --> PR["post-receive\n(after accepting push)"]

Client-side hooks run on your machine and can be bypassed. Server-side hooks run on the remote and cannot be bypassed by clients.

Architecture or Flow Diagram


flowchart LR
    EDIT["Edit Code"] -->|git add| STAGE["Stage Changes"]
    STAGE -->|git commit| PRE_COMMIT["pre-commit hook"]
    PRE_COMMIT -->|exit 0| PREP_MSG["prepare-commit-msg"]
    PRE_COMMIT -->|exit 1| ABORT1["Abort Commit"]
    PREP_MSG --> EDIT_MSG["Edit Commit Message"]
    EDIT_MSG --> COMMIT_MSG["commit-msg hook"]
    COMMIT_MSG -->|exit 0| COMMIT["Create Commit"]
    COMMIT_MSG -->|exit 1| ABORT2["Abort Commit"]
    COMMIT -->|git push| PRE_PUSH["pre-push hook"]
    PRE_PUSH -->|exit 0| PUSH["Push to Remote"]
    PRE_PUSH -->|exit 1| ABORT3["Abort Push"]
    PULL["git pull"] --> POST_MERGE["post-merge hook"]
    POST_MERGE -->|runs| DEPS["Install Dependencies"]

Hooks that exit with non-zero status abort the Git operation. This is how hooks enforce policies.

Step-by-Step Guide / Deep Dive

pre-commit Hook

Runs before the commit message editor opens. Ideal for linting and formatting:


#!/bin/bash
# .git/hooks/pre-commit

# Run linter on staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts|tsx)$')

if [ -n "$STAGED_FILES" ]; then
    echo "Running ESLint on staged files..."
    echo "$STAGED_FILES" | xargs npx eslint --max-warnings=0
    if [ $? -ne 0 ]; then
        echo "ESLint failed. Fix errors before committing."
        exit 1
    fi
fi

# Check for secrets
if git diff --cached | grep -E '(password|api_key|secret)\s*[:=]\s*["\x27][^"\x27]{8,}'; then
    echo "ERROR: Potential secret detected."
    exit 1
fi

exit 0

commit-msg Hook

Validates the commit message format:


#!/bin/bash
# .git/hooks/commit-msg

COMMIT_MSG=$(cat "$1")

# Enforce Conventional Commits format
if ! echo "$COMMIT_MSG" | grep -qE '^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,72}'; then
    echo "ERROR: Commit message must follow Conventional Commits format:"
    echo "  type(scope): description"
    echo "  Example: feat(auth): add login endpoint"
    exit 1
fi

exit 0

pre-push Hook

Runs before pushing. Ideal for running tests:


#!/bin/bash
# .git/hooks/pre-push

# Only run on main branch pushes
CURRENT_BRANCH=$(git symbolic-ref --short HEAD)

if [ "$CURRENT_BRANCH" = "main" ]; then
    echo "Running tests before pushing to main..."
    npm test
    if [ $? -ne 0 ]; then
        echo "Tests failed. Push aborted."
        exit 1
    fi
fi

exit 0

post-merge Hook

Runs after a successful merge or pull. Ideal for updating dependencies:


#!/bin/bash
# .git/hooks/post-merge

echo "Running post-merge hooks..."

# Check if package.json changed
if git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD | grep -q 'package.json'; then
    echo "package.json changed. Installing dependencies..."
    npm install
fi

# Check if database migrations changed
if git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD | grep -q 'migrations/'; then
    echo "Database migrations changed. Run migrations!"
    echo "  npm run db:migrate"
fi

Sharing Hooks Across Teams

Hooks aren’t versioned by default. Solutions:

Option 1: core.hooksPath (Git 2.9+)


# Store hooks in versioned directory
mkdir -p hooks
# Add hook scripts to hooks/
git config core.hooksPath hooks

Option 2: Husky (Node.js projects)


npx husky init
# Creates .husky/ directory with versioned hooks

Option 3: pre-commit framework


# .pre-commit-config.yaml is versioned
pre-commit install

Production Failure Scenarios + Mitigations

ScenarioSymptomsMitigation
Slow pre-commit hookDevelopers use --no-verifyOptimize hook; run only on staged files
Hook not installedTeam member commits without checksUse core.hooksPath or Husky
Hook fails in CIDifferent environment than localEnsure hook scripts are portable
Hook conflicts with toolingIDE auto-format conflictsCoordinate hook and tool configurations
Bypassed hooksgit push --no-verifyUse server-side hooks as backup

Trade-offs

AspectAdvantageDisadvantage
Local hooksFast feedback, customizableCan be bypassed, not shared
Server hooksCannot be bypassedSlower feedback, server-side only
HuskyVersioned, easy setupNode.js dependency
pre-commit frameworkMulti-language, well-maintainedPython dependency
core.hooksPathNative Git, simpleRequires manual setup per repo

Implementation Snippets


# List all available hooks
ls -la .git/hooks/

# Enable a sample hook
mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

# Configure shared hooks directory
git config core.hooksPath .githooks

# Husky setup
npx husky add .husky/pre-commit "npm run lint"
npx husky add .husky/pre-push "npm test"

# Skip hooks (emergency only)
git commit --no-verify -m "Emergency fix"
git push --no-verify

# Debug hook execution
GIT_TRACE=1 git commit -m "test"

Observability Checklist

  • Monitor: Hook execution time (should be < 5 seconds)
  • Track: Hook bypass rate (--no-verify usage)
  • Verify: All team members have hooks installed
  • Audit: Hook scripts for security (they run with user permissions)
  • Alert: Hook failures in CI/CD pipelines

Security/Compliance Notes

  • Hooks run with the same permissions as the user — validate hook sources
  • Don’t store secrets in hook scripts
  • Server-side hooks (pre-receive) provide enforcement that can’t be bypassed
  • See Git Secrets Management for secret prevention

Common Pitfalls / Anti-Patterns

  • Making hooks too slow — developers will bypass them
  • Not versioning hooks — team members have different checks
  • Using hooks for things CI should do — hooks are local, CI is authoritative
  • Ignoring hook output — developers miss important warnings
  • Hardcoding paths in hooks — breaks on different machines

Quick Recap Checklist

  • pre-commit: lint, format, secret detection before commit
  • commit-msg: validate commit message format
  • pre-push: run tests before pushing
  • post-merge: install dependencies, run migrations
  • Hooks exit 0 = proceed, exit 1 = abort
  • Share hooks via core.hooksPath, Husky, or pre-commit
  • Server-side hooks cannot be bypassed

Interview Q&A

What's the difference between pre-commit and commit-msg hooks?

pre-commit runs before the commit message editor opens — it doesn't have access to the message. commit-msg runs after the message is written — it receives the message file path as $1 and can validate or modify it. Use pre-commit for code checks, commit-msg for message validation.

How do you share Git hooks across a team?

Since .git/hooks/ isn't versioned, use one of: 1) git config core.hooksPath .githooks to point to a versioned directory, 2) Husky for Node.js projects (stores hooks in .husky/), or 3) pre-commit framework with a versioned .pre-commit-config.yaml. All three ensure every developer gets the same hooks.

Can Git hooks be bypassed?

Client-side hooks can be bypassed with --no-verify flag (git commit --no-verify, git push --no-verify). Server-side hooks (pre-receive, update, post-receive) cannot be bypassed by clients. For critical enforcement, always use server-side hooks or CI/CD pipelines as a backup.

What arguments do Git hooks receive?

Each hook receives different arguments: pre-commit gets none, commit-msg gets the message file path ($1), pre-push gets remote name and URL ($1, $2) with push details on stdin, post-merge gets a squash flag ($1). Check git githooks documentation for the complete reference.

Production Failure: Hook Blocking Deployment

Scenario: Hook failure in CI environment


# What happened:
# 1. Pre-commit hook runs ESLint on staged files
# 2. CI environment has different Node.js version
# 3. ESLint crashes with incompatible syntax
# 4. All PRs blocked — deployment pipeline halted

# Symptoms
$ git push
Running pre-push tests...
node:internal/modules/cjs/loader:1143
  throw err;
  ^
Error: Cannot find module 'eslint'

# Root cause: Hook assumes local environment matches CI

# Recovery steps:

# 1. Emergency bypass (temporary)
git push --no-verify  # Only for emergencies!

# 2. Fix the hook for portability
# Bad hook (assumes global npm):
npx eslint --max-warnings=0

# Good hook (checks environment):
#!/bin/bash
if command -v npx &> /dev/null; then
    npx eslint --max-warnings=0
elif [ -f node_modules/.bin/eslint ]; then
    node_modules/.bin/eslint --max-warnings=0
else
    echo "WARNING: ESLint not available, skipping"
    exit 0  # Don't block on missing tooling
fi

# 3. Add environment detection
#!/bin/bash
# Skip hooks in CI if CI tooling handles the same checks
if [ -n "$CI" ]; then
    echo "CI environment — skipping local hook"
    exit 0
fi

# 4. Prevent future issues:
# - Test hooks in clean environments (Docker)
# - Use containerized hooks (pre-commit framework)
# - Have CI as the authoritative gate, not local hooks

Trade-offs: Native Hooks vs Husky vs Pre-commit Framework

AspectNative HooksHuskyPre-commit Framework
PortabilityShell scripts onlyNode.js requiredPython required
Team setupManual per developernpm install + husky initpip install pre-commit
VersioningVia core.hooksPath.husky/ directory.pre-commit-config.yaml
Language supportAny (shell scripts)Any (runs commands)Any (Docker, system, Python)
CachingNoneNoneBuilt-in (skips unchanged files)
UpdatesManualVia npmpre-commit autoupdate
Cross-platformUnix-only (mostly)Cross-platformCross-platform
ComplexityLowMediumMedium
Best forSimple scripts, small teamsNode.js projectsMulti-language, large teams

Security/Compliance: Hook Execution Risks

Privilege escalation risks:

  • Hooks run with the same permissions as the user executing Git
  • A malicious hook can execute arbitrary commands on the developer’s machine
  • Hooks from untrusted sources (cloned repos with core.hooksPath) are dangerous

Security best practices:

  1. Never clone with hooks enabled from untrusted sources

    
    git clone --config core.hooksPath=/dev/null https://untrusted-repo.git
    
  2. Audit hook scripts before installing

    
    # Review all hooks in a repo
    find .git/hooks/ -type f -not -name "*.sample" -exec cat {} \;
    
  3. Don’t store secrets in hooks

    • Hooks are often committed to repos
    • Use environment variables or secret managers
  4. Validate hook sources

    • Only install hooks from trusted repositories
    • Pin versions in .pre-commit-config.yaml
    • Review hook code before adding to config
  5. Compliance considerations

    • Hooks can be used to enforce compliance (license checks, secret scanning)
    • Document which hooks are required for your compliance framework
    • Server-side hooks (pre-receive) provide stronger enforcement than client-side

Cross-Roadmap References

Resources

Category

Related Posts

Automated Changelog Generation: From Commit History to Release Notes

Build automated changelog pipelines from git commit history using conventional commits, conventional-changelog, and semantic-release. Learn parsing, templating, and production patterns.

#git #version-control #changelog

Git Aliases and Custom Commands: Productivity Through Automation

Create powerful Git aliases, custom scripts, and command extensions. Learn git extras, shell function integration, and team-wide alias standardization for faster workflows.

#git #version-control #aliases

Automated Releases and Tagging

Automate Git releases with tags, release notes, GitHub Releases, and CI/CD integration for consistent, repeatable software delivery.

#git #version-control #automation