Git Hooks: The Complete Guide for 2026
Git hooks are one of the most underused features in Git. They let you run custom scripts automatically at key points in the workflow — before a commit, after a merge, before a push, and more. With hooks you can enforce code quality, validate commit messages, run tests, prevent secrets from being committed, and automate tedious tasks that developers otherwise forget.
This guide covers every hook type with practical examples, the best tools for managing hooks across a team (Husky, lint-staged, lefthook, pre-commit), writing hooks in bash, Python, and Node.js, server-side hooks for repository governance, and real-world recipes you can copy into your projects today.
Table of Contents
- What Are Git Hooks?
- Client-Side vs Server-Side Hooks
- Hook Installation
- pre-commit Hook
- commit-msg Hook
- pre-push Hook
- prepare-commit-msg Hook
- post-merge Hook
- pre-rebase Hook
- Tools: Husky, lint-staged, lefthook, pre-commit
- Sharing Hooks with Your Team
- Writing Hooks in Bash, Python, and Node.js
- Bypassing Hooks
- Server-Side Hooks
- Real-World Recipes
- Troubleshooting
- FAQ
What Are Git Hooks?
Git hooks are executable scripts that Git runs automatically at specific points in the workflow. Every repository has a .git/hooks/ directory, and each file in it corresponds to a specific event. If a hook exits with a non-zero status code, the Git operation is aborted.
Here is a minimal example. This pre-commit hook prevents commits containing console.log:
#!/bin/sh
# .git/hooks/pre-commit
if git diff --cached --diff-filter=ACM | grep -q 'console\.log'; then
echo "ERROR: console.log found in staged files."
exit 1
fi
exit 0
The hook lifecycle during a typical commit-and-push workflow:
git commit:
1. pre-commit → Validate staged changes
2. prepare-commit-msg → Modify the default message
3. commit-msg → Validate the final message
4. post-commit → Notification (cannot abort)
git push:
5. pre-push → Validate before sending to remote
Remote server:
6. pre-receive → Validate all incoming refs
7. update → Validate each ref individually
8. post-receive → Deploy, notify (cannot abort)
Client-Side vs Server-Side Hooks
Client-side hooks run on the developer's machine. They are triggered by local operations (commit, merge, push) and can be skipped with --no-verify.
| Hook | Trigger | Abort? | Use Case |
|---|---|---|---|
pre-commit | Before commit created | Yes | Linting, formatting, tests |
prepare-commit-msg | Before editor opens | Yes | Auto-populate message |
commit-msg | After message entered | Yes | Validate message format |
post-commit | After commit created | No | Notifications |
pre-push | Before push to remote | Yes | Full test suite, branch policy |
post-merge | After merge completes | No | Install dependencies |
pre-rebase | Before rebase starts | Yes | Protect published branches |
post-checkout | After checkout/switch | No | Rebuild, update submodules |
Server-side hooks run on the remote Git server during push operations. They cannot be bypassed by the client, making them the only way to truly enforce policies.
| Hook | Trigger | Use Case |
|---|---|---|
pre-receive | Before any refs updated | Enforce branch protection, block large files |
update | Before each ref updated | Per-branch policies |
post-receive | After all refs updated | Trigger deploys, send notifications |
Hook Installation and the .git/hooks/ Directory
Every repository has a .git/hooks/ directory created by git init, pre-populated with sample scripts:
$ ls .git/hooks/
applypatch-msg.sample pre-commit.sample
commit-msg.sample pre-merge-commit.sample
post-update.sample pre-push.sample
pre-applypatch.sample pre-rebase.sample
prepare-commit-msg.sample
To create a hook, add an executable file with the exact hook name (no extension):
# Create and activate a pre-commit hook
touch .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
The first line must be a shebang: #!/bin/sh, #!/usr/bin/env bash, #!/usr/bin/env python3, or #!/usr/bin/env node.
Since Git 2.9, you can change the hooks directory with core.hooksPath, which is how you share hooks via version control:
# Use a committed directory instead of .git/hooks/
git config core.hooksPath .githooks
pre-commit Hook
The most commonly used hook. It runs after git commit but before the commit object is created. Exit non-zero to abort.
Lint Staged Files
#!/bin/sh
STAGED=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts|tsx)$')
[ -z "$STAGED" ] && exit 0
echo "Running ESLint on staged files..."
echo "$STAGED" | xargs npx eslint --max-warnings 0
if [ $? -ne 0 ]; then
echo "ESLint failed. Fix errors before committing."
exit 1
fi
Prevent Secrets from Being Committed
#!/bin/sh
PATTERNS="AWS_SECRET|PRIVATE_KEY|password\s*=\s*['\"]|-----BEGIN.*KEY-----|sk-[a-zA-Z0-9]{20,}"
STAGED=$(git diff --cached --diff-filter=ACM -p)
if echo "$STAGED" | grep -qEi "$PATTERNS"; then
echo "ERROR: Potential secret found in staged changes!"
echo "$STAGED" | grep -Eni "$PATTERNS" | head -5
exit 1
fi
Check Formatting
#!/bin/sh
STAGED=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts|css|json|md)$')
if [ -n "$STAGED" ]; then
echo "$STAGED" | xargs npx prettier --check || {
echo "Run: npx prettier --write ."; exit 1;
}
fi
commit-msg Hook
Runs after you write your commit message. Git passes the temp file path as $1. Exit non-zero to abort. This is the standard way to enforce commit message conventions.
#!/bin/sh
# Enforce Conventional Commits format
MSG=$(cat "$1")
PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,}"
if ! echo "$MSG" | head -1 | grep -qE "$PATTERN"; then
echo "ERROR: Message does not follow Conventional Commits."
echo ""
echo "Format: type(scope): description"
echo "Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert"
echo ""
echo "Examples:"
echo " feat(auth): add OAuth2 login flow"
echo " fix: resolve null pointer in user service"
echo ""
echo "Your message: $MSG"
exit 1
fi
# Enforce 72-char subject line
FIRST_LINE=$(head -1 "$1")
if [ ${#FIRST_LINE} -gt 72 ]; then
echo "ERROR: Subject line exceeds 72 characters (${#FIRST_LINE})."
exit 1
fi
To require a ticket number instead:
#!/bin/sh
MSG=$(cat "$1")
if ! echo "$MSG" | grep -qE '\b[A-Z]+-[0-9]+\b'; then
echo "ERROR: Commit message must include a ticket (e.g., PROJ-123)."
exit 1
fi
pre-push Hook
Runs before data is sent to the remote. Receives the remote name and URL as arguments, plus ref info on stdin. The last line of defense before code reaches the server.
#!/bin/sh
# Run full test suite before pushing
echo "Running tests before push..."
npm test 2>&1
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
exit 1
fi
#!/bin/sh
# Block force pushes to protected branches
while read local_ref local_oid remote_ref remote_oid; do
if echo "$remote_ref" | grep -qE 'refs/heads/(main|master|develop)$'; then
if [ "$remote_oid" != "0000000000000000000000000000000000000000" ]; then
MERGE_BASE=$(git merge-base "$remote_oid" "$local_oid" 2>/dev/null)
if [ "$MERGE_BASE" != "$remote_oid" ]; then
echo "ERROR: Force push to ${remote_ref} is not allowed."
exit 1
fi
fi
fi
done
prepare-commit-msg Hook
Runs before the commit message editor opens. Receives the message file path, the source (message, template, merge, squash), and optionally a commit hash. Use it to auto-populate messages.
#!/bin/sh
# Auto-insert branch ticket number into commit message
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
if [ "$COMMIT_SOURCE" = "message" ] || [ -z "$COMMIT_SOURCE" ]; then
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
TICKET=$(echo "$BRANCH" | grep -oE '[A-Z]+-[0-9]+')
if [ -n "$TICKET" ]; then
if ! grep -q "$TICKET" "$COMMIT_MSG_FILE"; then
sed -i.bak "1s/^/[$TICKET] /" "$COMMIT_MSG_FILE"
fi
fi
fi
With a branch named feature/PROJ-456-add-login, every commit message automatically starts with [PROJ-456].
post-merge Hook
Runs after a successful merge (including git pull). Cannot abort the operation. Ideal for keeping dependencies in sync.
#!/bin/sh
# Auto-install dependencies when lock files change
CHANGED=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)
if echo "$CHANGED" | grep -q 'package-lock.json\|yarn.lock'; then
echo "Lock file changed. Running npm ci..."
npm ci
fi
if echo "$CHANGED" | grep -q 'requirements.txt\|poetry.lock'; then
echo "Python deps changed. Installing..."
pip install -r requirements.txt
fi
if echo "$CHANGED" | grep -q 'migrations/'; then
echo "New migrations detected. Run: python manage.py migrate"
fi
pre-rebase Hook
Runs before git rebase starts. Exit non-zero to prevent the rebase. Useful for protecting branches that have been pushed to a remote.
#!/bin/sh
BRANCH="${2:-$(git symbolic-ref --short HEAD)}"
# Never allow rebasing protected branches
if echo "$BRANCH" | grep -qE '^(main|master|develop|release/.*)$'; then
echo "ERROR: Rebasing '$BRANCH' is not allowed. Use merge instead."
exit 1
fi
Tools: Husky, lint-staged, lefthook, pre-commit
Husky
The most popular hook manager for Node.js projects. Stores hooks in a committed .husky/ directory using core.hooksPath under the hood.
# Setup
npm install --save-dev husky
npx husky init
# Creates .husky/pre-commit with a default script
# .husky/pre-commit
npx lint-staged
// package.json - auto-install hooks for teammates
{ "scripts": { "prepare": "husky" } }
lint-staged
Companion tool (often paired with Husky) that runs linters only on staged files, making pre-commit checks fast even in large codebases.
npm install --save-dev lint-staged
// package.json
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix --max-warnings 0", "prettier --write"],
"*.{css,scss}": ["stylelint --fix", "prettier --write"],
"*.{json,md,yaml}": ["prettier --write"]
}
}
lefthook
Language-agnostic hook manager written in Go. Faster than Husky because it runs hooks in parallel with no Node.js dependency.
# lefthook.yml
pre-commit:
parallel: true
commands:
lint:
glob: "*.{js,ts,jsx,tsx}"
run: npx eslint --fix {staged_files}
stage_fixed: true
format:
glob: "*.{js,ts,css,json,md}"
run: npx prettier --write {staged_files}
stage_fixed: true
typecheck:
glob: "*.{ts,tsx}"
run: npx tsc --noEmit
commit-msg:
commands:
conventional:
run: npx commitlint --edit {1}
pre-push:
commands:
test:
run: npm test
pre-commit Framework
Python-based tool supporting hooks from any language via a plugin repository system. Popular in Python, Go, and multi-language projects.
pip install pre-commit
pre-commit install
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: detect-private-key
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.8
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
Tool Comparison
| Feature | Husky | lefthook | pre-commit |
|---|---|---|---|
| Written in | Node.js | Go | Python |
| Config | Shell scripts | YAML | YAML |
| Parallel | No | Yes | No |
| Staged filtering | Via lint-staged | Built-in | Built-in |
| Best for | Node.js projects | Polyglot, large repos | Python, multi-lang |
Sharing Hooks with Your Team
The .git/hooks/ directory is not tracked by Git, so hooks are not shared by default. Here are four proven approaches.
Approach 1: Husky + package.json — The prepare script runs automatically on npm install, so hooks are set up for every team member without manual steps.
Approach 2: Committed .githooks/ directory — Store hooks in .githooks/ and configure Git:
mkdir .githooks
cp .git/hooks/pre-commit .githooks/
chmod +x .githooks/pre-commit
# Add to Makefile or setup script:
git config core.hooksPath .githooks
Approach 3: lefthook — Commit lefthook.yml to the repo. After cloning, run lefthook install.
Approach 4: pre-commit framework — Commit .pre-commit-config.yaml. After cloning, run pre-commit install.
Writing Hooks in Bash, Python, and Node.js
Here is the same check (blocking debug statements) in three languages.
Bash
#!/usr/bin/env bash
set -euo pipefail
DIFF=$(git diff --cached --diff-filter=ACM -p)
for pattern in 'console\.log(' 'debugger;' 'pdb\.set_trace()' 'breakpoint()'; do
if echo "$DIFF" | grep -qE "^\+.*$pattern"; then
echo "Found '$pattern' in staged changes. Remove before committing."
exit 1
fi
done
exit 0
Python
#!/usr/bin/env python3
import subprocess, sys, re
PATTERNS = [r'console\.log\(', r'\bdebugger\b', r'pdb\.set_trace\(\)', r'breakpoint\(\)']
diff = subprocess.run(['git', 'diff', '--cached', '-p'], capture_output=True, text=True).stdout
errors = []
for line in diff.splitlines():
if not line.startswith('+') or line.startswith('+++'): continue
for p in PATTERNS:
if re.search(p, line):
errors.append(line.strip())
if errors:
print("Debug statements found:")
for e in errors[:10]: print(f" {e}")
sys.exit(1)
sys.exit(0)
Node.js
#!/usr/bin/env node
const { execSync } = require('child_process');
const PATTERNS = [/console\.log\(/, /\bdebugger\b/, /breakpoint\(\)/];
const diff = execSync('git diff --cached -p', { encoding: 'utf-8' });
const errors = [];
diff.split('\n').forEach((line) => {
if (!line.startsWith('+') || line.startsWith('+++')) return;
for (const p of PATTERNS) {
if (p.test(line)) { errors.push(line.trim()); break; }
}
});
if (errors.length) {
console.error('Debug statements found:');
errors.slice(0, 10).forEach((e) => console.error(` ${e}`));
process.exit(1);
}
Best Practices
- Use
#!/usr/bin/envfor portable shebang lines across macOS, Linux, and CI - Exit early when no relevant staged files exist
- Provide clear error messages explaining what failed and how to fix it
- Keep hooks fast — target under 5 seconds for pre-commit. Move slow checks to pre-push
- Use
--diff-filter=ACMto skip deleted files
Bypassing Hooks (--no-verify)
# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "emergency hotfix"
git commit -n -m "emergency hotfix" # shorthand
# Skip pre-push hook
git push --no-verify
Acceptable: emergency hotfixes, WIP commits on personal branches, fixing a broken hook itself.
Not acceptable: routinely skipping to avoid lint errors, pushing to shared branches without checks, committing secrets.
--no-verify only affects client-side hooks. Server-side hooks cannot be bypassed. Critical policies should always be enforced server-side.
If your team frequently bypasses hooks, the hooks are probably too slow or too strict. Fix the hooks instead of normalizing --no-verify.
Server-Side Hooks
pre-receive — Validate All Incoming Refs
#!/bin/sh
# Block large files and enforce commit message format
while read oldrev newrev refname; do
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
RANGE=$([ "$oldrev" = "0000000000000000000000000000000000000000" ] && echo "$newrev" || echo "$oldrev..$newrev")
for commit in $(git rev-list "$RANGE"); do
MSG=$(git log --format=%s -1 "$commit")
if ! echo "$MSG" | grep -qE '^(feat|fix|docs|refactor|test|chore)'; then
echo "REJECTED: Commit $commit - invalid message format."
exit 1
fi
done
done
update — Per-Branch Policies
#!/bin/sh
REFNAME="$1"; OLDREV="$2"; NEWREV="$3"
BRANCH=$(echo "$REFNAME" | sed 's|refs/heads/||')
# Only allow fast-forward pushes to main
if [ "$BRANCH" = "main" ]; then
MERGE_BASE=$(git merge-base "$OLDREV" "$NEWREV" 2>/dev/null)
if [ "$MERGE_BASE" != "$OLDREV" ]; then
echo "ERROR: Non-fast-forward push to main is not allowed."
exit 1
fi
fi
post-receive — Auto-Deploy
#!/bin/sh
while read oldrev newrev refname; do
BRANCH=$(echo "$refname" | sed 's|refs/heads/||')
if [ "$BRANCH" = "main" ]; then
echo "Deploying to production..."
GIT_WORK_TREE=/var/www/myapp git checkout -f main
cd /var/www/myapp && npm ci --production && pm2 restart myapp
echo "Deployment complete."
fi
done
Real-World Recipes and Patterns
Recipe 1: Complete Node.js Setup (Husky + lint-staged + commitlint)
# Install everything
npm install --save-dev husky lint-staged @commitlint/cli @commitlint/config-conventional
npx husky init
# .husky/pre-commit
npx lint-staged
# .husky/commit-msg
npx --no -- commitlint --edit $1
// commitlint.config.js
module.exports = { extends: ['@commitlint/config-conventional'] };
// package.json
{
"scripts": { "prepare": "husky" },
"lint-staged": {
"*.{js,ts,tsx}": ["eslint --fix --max-warnings 0", "prettier --write"],
"*.{css,json,md}": ["prettier --write"]
}
}
Recipe 2: Multi-Purpose Safety Hook
#!/bin/sh
# pre-commit - Catch common mistakes
# Block direct commits to main
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
echo "ERROR: Direct commits to $BRANCH not allowed. Create a feature branch."
exit 1
fi
# Check for merge conflict markers
CONFLICTS=$(git diff --cached --name-only | xargs grep -lE '^(<<<<<<<|=======|>>>>>>>)' 2>/dev/null)
if [ -n "$CONFLICTS" ]; then
echo "ERROR: Merge conflict markers found in: $CONFLICTS"
exit 1
fi
# Block .env files
ENV=$(git diff --cached --name-only | grep -E '\.env(\..+)?$')
if [ -n "$ENV" ]; then
echo "ERROR: Attempting to commit env files: $ENV"
exit 1
fi
Recipe 3: Python Project (pre-commit framework + ruff)
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: detect-private-key
- id: check-added-large-files
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.8
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: local
hooks:
- id: pytest
name: pytest
entry: python -m pytest --tb=short -q
language: system
types: [python]
pass_filenames: false
stages: [push]
Recipe 4: Monorepo with lefthook
# lefthook.yml
pre-commit:
parallel: true
commands:
frontend-lint:
root: packages/frontend/
glob: "*.{ts,tsx}"
run: npx eslint --fix {staged_files}
stage_fixed: true
backend-lint:
root: packages/backend/
glob: "*.py"
run: ruff check --fix {staged_files}
stage_fixed: true
pre-push:
commands:
frontend-test:
root: packages/frontend/
run: npm test
backend-test:
root: packages/backend/
run: python -m pytest --tb=short
Troubleshooting
Hook Not Executing
- Missing execute permission —
chmod +x .git/hooks/pre-commit - .sample extension — Rename:
mv pre-commit.sample pre-commit - Wrong shebang — First line must be
#!/bin/shor similar - core.hooksPath override — Check:
git config core.hooksPath - Windows CRLF line endings — Fix:
dos2unix .git/hooks/pre-commit
Command Not Found in Hook
Git hooks run with a minimal PATH. Fix by sourcing your shell profile or using full paths:
#!/bin/sh
# Option 1: Extend PATH
export PATH="/usr/local/bin:$HOME/.nvm/versions/node/v20.11.0/bin:$PATH"
# Option 2: Source nvm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
npx eslint .
Hook Is Too Slow
- Only check staged files — Use
git diff --cached --name-onlyinstead of linting everything - Use lint-staged or lefthook — They handle staged-file filtering automatically
- Run in parallel — lefthook supports this natively
- Move slow checks to pre-push — Full test suites do not belong in pre-commit
- Enable caching — e.g.,
eslint --cache
Debugging
# Run hook directly
.git/hooks/pre-commit
# Trace Git's hook execution
GIT_TRACE=1 git commit -m "test"
# Check hooks directory
git config core.hooksPath
Frequently Asked Questions
What are Git hooks and where are they stored?
Git hooks are scripts that Git executes automatically before or after events like commit, push, and merge. They are stored in the .git/hooks/ directory of every Git repository. To activate a hook, create an executable file with the hook name (no extension) and a valid shebang line.
How do I share Git hooks with my team?
The .git/hooks/ directory is not tracked by Git. Use Husky (stores hooks in .husky/), set core.hooksPath to a committed directory like .githooks/, or use lefthook or the pre-commit framework, which both use committed config files.
How do I skip or bypass Git hooks?
Pass --no-verify (or -n) to git commit or git push. This skips client-side hooks only. Server-side hooks (pre-receive, update) cannot be bypassed because they run on the remote server.
What is the difference between Husky, lint-staged, lefthook, and pre-commit?
Husky manages Git hooks for Node.js projects. lint-staged runs linters on staged files only. lefthook is a Go-based, language-agnostic hook manager with parallel execution. The pre-commit framework is Python-based with a plugin repository system. Choose Husky + lint-staged for Node.js, lefthook for polyglot projects, and pre-commit for Python teams.
Why is my Git hook not running?
Check: (1) file has execute permission (chmod +x), (2) no .sample extension, (3) valid shebang line, (4) core.hooksPath is not pointing elsewhere, (5) no Windows CRLF line endings, (6) Git was not invoked with --no-verify.
Can I use Git hooks with a CI/CD pipeline?
Client-side hooks do not run in CI. Server-side hooks run on the Git server. For CI/CD, use GitHub Actions, GitLab CI, or Bitbucket Pipelines to run the same checks your local hooks perform. Self-hosted Git servers support server-side hooks directly.
Continue Learning
This guide is part of our Git deep-dive series. Explore the related guides:
- The Complete Guide to Git — Fundamentals through advanced workflows
- Git Commands Every Developer Should Know — Essential command reference
- Git Branching Strategies Guide — Git Flow, GitHub Flow, and Trunk-Based Development
- Git Rebase: The Complete Guide — Interactive rebase, squash, and conflict resolution