GitGit Hooks

Git Hooks: Introduction

Git hooks are executable scripts that Git runs automatically at specific points in your workflow — before a commit is saved, after a push lands, when a merge completes, and many other moments. They let you enforce rules, automate housekeeping, and connect Git to the rest of your toolchain without any external services.

Where hooks live

Bash
# Every Git repo has a hooks directory
ls .git/hooks/

Default hook samples

Text
applypatch-msg.sample     pre-applypatch.sample
commit-msg.sample         pre-commit.sample
fsmonitor-watchman.sample pre-merge-commit.sample
post-update.sample        pre-push.sample
pre-applypatch.sample     pre-rebase.sample
prepare-commit-msg.sample update.sample

Every hook ships as a .sample file. Git ignores any file that ends in .sample. To activate a hook, remove the .sample extension and make the file executable.

Bash
# Activate the pre-commit hook
cp .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
Two categories of hooks

Category

Where they run

Who can use them

Examples

Client-side

Developer's local machine

Every contributor (opt-in per machine)

pre-commit, commit-msg, pre-push

Server-side

Remote Git server (bare repo)

Repository admins only

pre-receive, update, post-receive

Complete hooks reference

Hook

Trigger point

Can abort?

Category

pre-commit

Before the commit message editor opens

Yes (exit non-zero)

Client

prepare-commit-msg

After the default message is prepared, before the editor opens

Yes

Client

commit-msg

After the message is saved, before the commit is finalised

Yes

Client

post-commit

After the commit is fully created

No (informational)

Client

pre-rebase

Before a rebase starts

Yes

Client

post-checkout

After git checkout or git switch completes

No

Client

post-merge

After a successful git merge

No

Client

pre-push

Before objects are transferred to the remote

Yes

Client

pre-receive

Before any refs are updated on the server

Yes (reject all)

Server

update

Once per ref being updated on the server

Yes (reject that ref)

Server

post-receive

After all refs are updated on the server

No (informational)

Server

post-update

After update hook, once for all refs

No

Server

pre-applypatch

Before git am applies a patch

Yes

Client

applypatch-msg

After patch message is loaded by git am

Yes

Client

post-applypatch

After git am applies a patch

No

Client

push-to-checkout

On a non-bare repo when pushed to the checked-out branch

Yes

Server

pre-merge-commit

Before a merge commit is created

Yes

Client

sendemail-validate

Before git send-email transmits patches

Yes

Client

fsmonitor-watchman

Called by filesystem monitors to speed up status queries

N/A

Client

How exit codes control Git

Every hook is a program. Git interprets its exit code:

Exit code

Effect

0

Hook passed — Git proceeds with the operation.

Non-zero (e.g., 1)

Hook failed — Git aborts the operation and shows the hook's stdout/stderr.

125 (special, in bisect run)

Tells git bisect to skip the commit rather than marking it bad.

Minimal hook that always aborts

Bash
#!/usr/bin/env bash
echo "This hook prevents all commits."
exit 1

Minimal hook that always allows

Bash
#!/usr/bin/env bash
exit 0
Writing your first hook

.git/hooks/pre-commit — prevent committing to main

Bash
#!/usr/bin/env bash
branch=$(git symbolic-ref --short HEAD)

if [ "$branch" = "main" ]; then
  echo "⛔  Direct commits to 'main' are not allowed."
  echo "    Create a feature branch: git checkout -b feature/my-change"
  exit 1
fi

exit 0

Bash
chmod +x .git/hooks/pre-commit

What the developer sees on a blocked commit

Text
$ git commit -m "quick fix"
⛔  Direct commits to 'main' are not allowed.
    Create a feature branch: git checkout -b feature/my-change
What language can hooks be written in?

Any language whose interpreter is available on the machine — the shebang line (#!) on the first line controls execution:

Language

Shebang

Bash (most common)

#!/usr/bin/env bash

Python

#!/usr/bin/env python3

Node.js

#!/usr/bin/env node

Ruby

#!/usr/bin/env ruby

Perl

#!/usr/bin/env perl

Any compiled binary

(no shebang needed — just make it executable)

Hooks are NOT committed by default

The .git/ directory is never committed — which means hooks you write locally are invisible to your teammates. There are three popular patterns for sharing hooks:

Approach

How it works

Pros

Cons

Husky (Node.js)

Stores hooks in .husky/ (committed), installs them via npm prepare script.

Easy setup, widely adopted in JS projects.

Requires Node.js; setup step needed.

pre-commit framework (Python)

YAML config in .pre-commit-config.yaml; hooks are downloaded on first run.

Language-agnostic, huge plugin ecosystem.

Requires Python; hooks are sandboxed.

Symlinks into a committed directory

Keep hooks in scripts/git-hooks/, symlink .git/hooks/ to them.

No extra tooling needed.

Manual setup per clone; platform-specific symlinks.

core.hooksPath in a shared config

git config core.hooksPath scripts/git-hooks committed to a shared .gitconfig.

Native Git support (Git 2.9+).

Must be set in local config; can't be committed.

Using core.hooksPath

Bash
# 1. Create a committed directory for hooks
mkdir -p scripts/git-hooks

# 2. Write your hooks there
cat > scripts/git-hooks/pre-commit << 'EOF'
#!/usr/bin/env bash
npm run lint --silent || exit 1
EOF
chmod +x scripts/git-hooks/pre-commit

# 3. Commit the directory
git add scripts/git-hooks
git commit -m "chore: add shared git hooks"

# 4. Each developer configures their local Git to use this directory
git config core.hooksPath scripts/git-hooks
# Or set it globally
git config --global core.hooksPath scripts/git-hooks
Husky setup (Node.js projects)

Bash
# Install husky
npm install --save-dev husky

# Initialise (creates .husky/ directory and adds prepare script)
npx husky init

# Add a pre-commit hook
echo "npm test" > .husky/pre-commit
chmod +x .husky/pre-commit

# Commit the .husky/ directory
git add .husky
git commit -m "chore: add husky hooks"
pre-commit framework (Python / polyglot projects)

Bash
pip install pre-commit

.pre-commit-config.yaml

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

  - repo: https://github.com/psf/black
    rev: 24.4.2
    hooks:
      - id: black

Bash
# Install the hooks defined in the config
pre-commit install

# Run all hooks manually on every file
pre-commit run --all-files
Warning
Client-side hooks can be bypassed with `git commit --no-verify` (or `-n`). They are a developer convenience tool and a first line of defence — not a security boundary. For enforcing policy (branch naming, signed commits, clean builds), use server-side hooks or CI checks that cannot be skipped by individual developers.
Hooks run in the repo root
Git always sets the working directory to the root of the repo before running a hook. Use relative paths from the repo root or use `git rev-parse --show-toplevel` if you need an absolute path.
Tip
Start every hook with `set -euo pipefail` in Bash. This makes the script exit immediately on any error (`-e`), on unbound variables (`-u`), and on pipeline failures (`-o pipefail`). Without these flags, a failing sub-command may be silently ignored and the hook exits 0.