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.
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
| Scenario | Symptoms | Mitigation |
|---|---|---|
| Slow pre-commit hook | Developers use --no-verify | Optimize hook; run only on staged files |
| Hook not installed | Team member commits without checks | Use core.hooksPath or Husky |
| Hook fails in CI | Different environment than local | Ensure hook scripts are portable |
| Hook conflicts with tooling | IDE auto-format conflicts | Coordinate hook and tool configurations |
| Bypassed hooks | git push --no-verify | Use server-side hooks as backup |
Trade-off Analysis
| Aspect | Advantage | Disadvantage |
|---|---|---|
| Local hooks | Fast feedback, customizable | Can be bypassed, not shared |
| Server hooks | Cannot be bypassed | Slower feedback, server-side only |
| Husky | Versioned, easy setup | Node.js dependency |
| pre-commit framework | Multi-language, well-maintained | Python dependency |
| core.hooksPath | Native Git, simple | Requires 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-verifyusage) - 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
| Aspect | Native Hooks | Husky | Pre-commit Framework |
|---|---|---|---|
| Portability | Shell scripts only | Node.js required | Python required |
| Team setup | Manual per developer | npm install + husky init | pip install pre-commit |
| Versioning | Via core.hooksPath | .husky/ directory | .pre-commit-config.yaml |
| Language support | Any (shell scripts) | Any (runs commands) | Any (Docker, system, Python) |
| Caching | None | None | Built-in (skips unchanged files) |
| Updates | Manual | Via npm | pre-commit autoupdate |
| Cross-platform | Unix-only (mostly) | Cross-platform | Cross-platform |
| Complexity | Low | Medium | Medium |
| Best for | Simple scripts, small teams | Node.js projects | Multi-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:
-
Never clone with hooks enabled from untrusted sources
git clone --config core.hooksPath=/dev/null https://untrusted-repo.git -
Audit hook scripts before installing
# Review all hooks in a repo find .git/hooks/ -type f -not -name "*.sample" -exec cat {} \; -
Don’t store secrets in hooks
- Hooks are often committed to repos
- Use environment variables or secret managers
-
Validate hook sources
- Only install hooks from trusted repositories
- Pin versions in
.pre-commit-config.yaml - Review hook code before adding to config
-
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
- Automated Testing in CI/CD — DevOps roadmap: testing hooks triggered by Git events
- DevOps Learning Roadmap — Broader DevOps context for hook-driven automation
Interview Questions
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
- Git Hooks Documentation (Official) — Complete reference for all hook types, arguments, and environment variables.
- Pro Git: Customizing Git - Git Hooks — The Git book’s comprehensive chapter on hook configuration and best practices.
- Atlassian Git Hooks Tutorial — Practical guide with examples for client-side and server-side hooks.
- Husky — Modern Git hooks manager for Node.js projects with versioned hook scripts.
- pre-commit Framework — Multi-language hook manager with caching, auto-updates, and extensive plugin ecosystem.
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 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.
Automated Releases and Tagging
Automate Git releases with tags, release notes, GitHub Releases, and CI/CD integration for consistent, repeatable software delivery.