Git Hooks: The Complete Guide for 2026

Published February 12, 2026 · 28 min read

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?

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-commitBefore commit createdYesLinting, formatting, tests
prepare-commit-msgBefore editor opensYesAuto-populate message
commit-msgAfter message enteredYesValidate message format
post-commitAfter commit createdNoNotifications
pre-pushBefore push to remoteYesFull test suite, branch policy
post-mergeAfter merge completesNoInstall dependencies
pre-rebaseBefore rebase startsYesProtect published branches
post-checkoutAfter checkout/switchNoRebuild, 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-receiveBefore any refs updatedEnforce branch protection, block large files
updateBefore each ref updatedPer-branch policies
post-receiveAfter all refs updatedTrigger 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 inNode.jsGoPython
ConfigShell scriptsYAMLYAML
ParallelNoYesNo
Staged filteringVia lint-stagedBuilt-inBuilt-in
Best forNode.js projectsPolyglot, large reposPython, 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

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.

Important: --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
Note: GitHub, GitLab, and Bitbucket do not allow custom server-side hooks on their hosted servers. Use their built-in features instead: GitHub Actions, GitLab CI, branch protection rules, and webhooks. Self-hosted GitLab, Gitea, and bare Git servers support server-side hooks directly.

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

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

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:

Related Tools

Git Diff Viewer
Visualize and compare diffs in the browser
JSON Formatter
Format and validate JSON data instantly
Regex Tester
Test regular expressions with live matching
Git Cheat Sheet
One-page quick reference for all Git commands