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: 17 min read author: Geek Workbench 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

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-off Analysis

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 Considerations

  • 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

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

Interview Questions

1. 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.

2. 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.

3. 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.

4. 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.

5. What is the execution order of client-side hooks during a commit?

The order is: 1) pre-commit runs first (before the editor opens), 2) prepare-commit-msg (can edit the default message), 3) commit-msg (validates the final message). If any hook exits with non-zero, the operation aborts.

6. How does the pre-push hook receive information about what will be pushed?

pre-push receives the remote name and URL as $1 and $2. The actual push details (list of refs and commits) are provided via stdin in the format: <local ref> <local sha1> <remote ref> <remote sha1>. You must read from stdin to get this information.

7. Why might a pre-commit hook run slowly, and how do you fix it?

Common causes: running linters/formatters on all files instead of just staged files, running multiple sequential checks, or using slow tools on large codebases. Fix: use git diff --cached --name-only to get only staged files, run checks in parallel where possible, and consider pre-commit frameworks with built-in caching.

8. How do you debug a Git hook that's not working as expected?

Methods: 1) Add set -x at the top of the hook script to trace execution, 2) Use GIT_TRACE=1 before the git command to see hook debugging output, 3) Use echo statements to print variable values, 4) Test the hook script directly outside of git to isolate the issue.

9. What's the difference between post-merge and post-checkout hooks?

post-merge runs after any git merge or git pull — it receives a squash flag ($1). post-checkout runs after git checkout — it receives the previous and new branch refs ($1, $2) plus a flag indicating if it's a branch switch or file checkout. Use post-merge for dependency updates, post-checkout for workspace state changes.

10. Can you modify files during a pre-commit hook? What are the implications?

Yes, but it's risky. Modifying staged files during pre-commit creates a mismatch between the editor's staged content and what gets committed. The staged snapshot may not match what the user saw. Safer approach: fail the commit if files need formatting and let the user re-stage the formatted version, or use a prepare-commit-msg hook to auto-format before the message editor opens.

11. How do you make Git hooks cross-platform compatible?

Strategies: 1) Use POSIX-compatible shell syntax (avoid Bash-specific features), 2) Use tools that work cross-platform (Node.js, Python instead of Unix-only commands), 3) Test on both Windows (Git Bash, WSL) and macOS/Linux, 4) Consider the pre-commit framework which handles cross-platform hook execution, 5) Avoid hardcoding paths — use git rev-parse --show-toplevel for repo-relative paths.

12. What is the security model for Git hooks? What should you never do in a hook?

Hooks run with the same permissions as the user executing Git. Never: 1) Execute code from untrusted sources, 2) Store secrets in hooks (they're often committed), 3) Clone repos with pre-configured core.hooksPath from untrusted sources, 4) Assume hook scripts haven't been tampered with. Always audit hooks before installation.

13. How does Husky v9 differ from earlier versions in setup and configuration?

Husky v9 moved to a declarative approach using .husky/pre-commit script files instead of the old package.json based husky install. The npx husky add command now creates individual hook files in .husky/ rather than modifying package.json. This makes hooks more transparent and easier to version control.

14. What happens when multiple hooks of the same type exist?

Git only supports one hook per type per repository. If you need multiple checks, either: 1) Chain them in a single hook script (exit on first failure), 2) Use a tool like pre-commit framework that can run multiple hooks of the same type, 3) Have one master script that calls sub-scripts. Native Git doesn't support multiple hooks of the same type.

15. How do you skip hooks in CI/CD environments safely?

Safe approaches: 1) Use environment variables (if [ -n "$CI" ]; then exit 0; fi) to detect CI and skip local hooks, 2) Have CI run the same quality checks independently (making local hooks redundant), 3) For emergency bypasses, use --no-verify but document and review such commits. The key is ensuring CI is the authoritative gate while local hooks provide fast feedback.

16. How does the post-rewrite hook work and what can you use it for?

The post-rewrite hook runs after commands that rewrite commits: git rebase and git commit --amend. It receives the command name as $1 and list of affected commits on stdin. Use it to: update external references (Jira tickets, CI builds), notify team members of rebased branches, or automatically clean up related artifacts. Unlike post-checkout, it only fires for actual rewrites, not regular checkouts.

17. What is the difference between running hooks manually versus through Git?

Hooks are designed to run within Git's execution environment with specific context: Git sets environment variables like GIT_DIR and passes hook-specific arguments. When run manually (e.g., ./.git/hooks/pre-commit), you bypass this context — arguments may not be set correctly, stdin may not receive expected data, and exit codes affect your shell rather than Git operation. Always trigger hooks through Git commands to ensure they function as intended.

18. How do you test a pre-commit hook without triggering it on actual commits?

Methods: 1) Use git commit --dry-run (some hooks still run), 2) Create a test commit with git commit -m "test" --no-verify then examine the result, 3) Run the hook script directly with echo statements to trace logic, 4) Use GIT_TRACE=1 to see hook execution details, 5) For lint hooks specifically, test against staged files with git diff --cached | your-linter before committing.

19. What are the limitations of client-side hooks for security enforcement?

Client-side hooks cannot reliably enforce security because: 1) They can be bypassed with --no-verify, 2) They are not copied when cloning (only .git/hooks/ templates), 3) Advanced users can modify or delete them, 4) They run on developer machines with variable configurations. For real security enforcement (blocking malicious commits, secret detection, license compliance), use server-side hooks (pre-receive on GitHub/GitLab) and CI/CD pipeline checks as the authoritative controls.

20. How do you implement a pre-commit hook that runs different checks for different file types?

Filter staged files by extension using git diff --cached --name-only and conditional checks. Example structure: STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM) then branch on file type with echo "$STAGED_FILES" | grep -E '\.js$|\.ts$' for JavaScript/TypeScript, grep -E '\.py$' for Python, etc. Run type-specific linters per branch. This ensures JavaScript hooks don't run on Python files and vice versa, keeping hook execution fast.

Further Reading

Conclusion

Git hooks are your personal CI pipeline that runs before anything leaves your machine. A well-configured pre-commit hook catches formatting issues and secrets; a pre-push hook runs tests. Together they shift quality left, catching problems at the earliest possible moment.

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