Skip to content

Git Hooks: Automate Your Git Workflow

Lesson 6 of 11Intermediate → Advanced11 min readModern Git Workflows · Modern Git ProductivityVerified: Git 2.43.0 on Ubuntu 24.04; every hook in this lesson was written and run

A hook is an executable script Git runs at a defined point in an operation. Before a commit is created, after a merge completes, before a push is sent — each has a hook name, and if an executable file with that name exists, Git runs it.

Hooks give you fast local feedback: a formatting error caught in two seconds beats one caught by CI in eight minutes. What they do not give you is enforcement, because anyone can bypass or simply not install them.

git init populates .git/hooks with disabled samples:

  • Directory.git/
    • Directoryhooks/
      • pre-commit.sample
      • commit-msg.sample
      • pre-push.sample
      • prepare-commit-msg.sample
      • post-update.sample

Every file ends in .sample, which is exactly why none of them run: Git executes a hook only when a file with the exact hook name exists and is executable.

Terminal window
mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

Two rules govern everything:

Exit status decides. Zero means proceed. Non-zero aborts the operation for “pre” hooks. For “post” hooks the operation has already happened, so the exit status is informational.

Standard error reaches the user. Write your explanation to stderr so it appears even when stdout is captured.

HookRunsCan abort?Typical use
pre-commitBefore the message is requestedYesLint, format, detect secrets
prepare-commit-msgBefore the editor opensYesInsert a template or ticket reference
commit-msgAfter the message is writtenYesValidate message format
post-commitAfter the commit existsNoNotifications
pre-pushBefore objects are sentYesRun tests, block protected branches
post-mergeAfter a merge completesNoReinstall dependencies
post-checkoutAfter checkout or switchNoRebuild generated files
pre-rebaseBefore a rebaseYesProtect specific branches

There are also server-side hooks — pre-receive, update, post-receive — which run on the remote when a push arrives. Those are enforcement, because the developer cannot bypass them. Most hosting platforms do not expose them directly and offer branch protection rules instead.

The most-used hook. It runs before Git asks for a commit message, so a failure costs you nothing.

#!/bin/sh
# .git/hooks/pre-commit — reject staged changes containing obvious credentials
if git diff --cached --name-only -z \
| xargs -0 -r grep -lEI 'AKIA[0-9A-Z]{16}|BEGIN [A-Z ]*PRIVATE KEY' 2>/dev/null; then
echo "pre-commit: possible credential detected in staged changes" >&2
exit 1
fi
Terminal window
chmod +x .git/hooks/pre-commit
git add cfg.py
git commit -m "feat(cfg): add config"
cfg.py
pre-commit: possible credential detected in staged changes

The commit does not happen. Three details in that script matter:

  • --cached examines what is staged, not the working tree. You are checking what is about to be committed.
  • -z with xargs -0 handles filenames containing spaces.
  • -I on grep skips binary files, avoiding noise.

Running a linter over the whole project on every commit gets slow. Restrict it to staged files:

#!/bin/sh
# .git/hooks/pre-commit — lint staged Python files only
files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')
[ -z "$files" ] && exit 0
if ! echo "$files" | xargs ruff check; then
echo "pre-commit: lint failures — fix them or commit with --no-verify" >&2
exit 1
fi

--diff-filter=ACM selects added, copied and modified files, skipping deletions — a deleted file cannot be linted.

Receives the path to the file containing the message as its first argument.

#!/bin/sh
# .git/hooks/commit-msg — require a conventional-commit subject line
pattern='^(feat|fix|docs|refactor|test|chore)(\([a-z0-9-]+\))?: .{1,}'
if ! grep -qE "$pattern" "$1"; then
echo "commit-msg: subject must look like 'feat(parser): add support for X'" >&2
exit 1
fi
Terminal window
git commit -m "bad message"
commit-msg: subject must look like 'feat(parser): add support for X'
Terminal window
git commit -m "feat(parser): add tokeniser"

Succeeds. This is one of the better uses of hooks: the check is instant, deterministic, and the feedback arrives exactly when you can act on it.

Runs before objects are sent, and receives the refs being pushed on standard input — one line per ref, with local and remote names and SHAs.

#!/bin/sh
# .git/hooks/pre-push — refuse direct pushes to protected branches
while read -r _local_ref _local_sha remote_ref _remote_sha; do
case "$remote_ref" in
refs/heads/main|refs/heads/release/*)
echo "pre-push: direct pushes to $remote_ref are not allowed" >&2
exit 1
;;
esac
done
exit 0

Running the test suite here is also common, though it makes every push slow. A useful compromise is to run only fast tests, and leave the full suite to CI.

Since .git/hooks is not cloned, sharing requires pointing Git somewhere that is.

Terminal window
git config core.hooksPath .githooks

What it doesTells Git to look for hooks in the given directory instead of .git/hooks.

Why we run itA directory inside the repository is version-controlled, so the whole team gets the same hooks from a clone.

Expected resultNo output. Hooks in that directory now run, and those in .git/hooks are ignored.

  1. Create the directory and commit the hooks:

    Terminal window
    mkdir .githooks
    mv .git/hooks/pre-commit .githooks/
    chmod +x .githooks/pre-commit
    git add .githooks && git commit -m "chore: add shared pre-commit hook"
  2. Point Git at it:

    Terminal window
    git config core.hooksPath .githooks
  3. Document the step, because it is per clone — core.hooksPath is local configuration and is not itself shared.

That third step is the catch. The hooks are shared; the setting that activates them is not. Teams handle it with a setup script:

scripts/setup.sh
#!/bin/sh
git config core.hooksPath .githooks
echo "Hooks enabled."

Or with a hook-management tool — pre-commit, husky, lefthook and similar all exist to solve exactly this and to manage hook dependencies.

A hook is any executable. The shebang decides the interpreter:

#!/usr/bin/env python3
"""commit-msg — reject messages whose subject exceeds 72 characters."""
import sys
msg_path = sys.argv[1]
with open(msg_path, encoding="utf-8") as fh:
subject = fh.readline().rstrip("\n")
if subject.startswith("#"):
sys.exit(0) # comment-only; Git will abort anyway
if len(subject) > 72:
print(f"commit-msg: subject is {len(subject)} characters; keep it under 72",
file=sys.stderr)
sys.exit(1)

The same rules apply: executable bit set, exact filename, non-zero exit to abort.

The “post” hooks cannot abort anything, which makes them useful for keeping your environment consistent rather than for checking things.

post-merge and post-checkout — reinstall dependencies when the lockfile changes:

#!/bin/sh
# .githooks/post-merge — reinstall if the lockfile changed
changed=$(git diff-tree -r --name-only ORIG_HEAD HEAD)
case "$changed" in
*package-lock.json*) echo "post-merge: lockfile changed, running npm ci"; npm ci ;;
esac

This removes an entire class of “it works on my machine” confusion, where someone pulls a dependency change and does not notice.

post-checkout receives three arguments: the previous HEAD, the new HEAD, and a flag that is 1 for a branch checkout and 0 for a file checkout. Check that flag, or the hook fires on every git restore:

#!/bin/sh
# .githooks/post-checkout — only act on branch switches
[ "$3" = "1" ] || exit 0
echo "Switched to $(git rev-parse --abbrev-ref HEAD)"

prepare-commit-msg — insert a ticket reference derived from the branch name:

#!/bin/sh
# .githooks/prepare-commit-msg — prefix the message with the branch's ticket ID
msg_file=$1
source=$2
# Do not interfere with merges, squashes or amends.
case "$source" in merge|squash|commit) exit 0 ;; esac
branch=$(git rev-parse --abbrev-ref HEAD)
ticket=$(echo "$branch" | grep -oE '[A-Z]+-[0-9]+' | head -1)
[ -z "$ticket" ] && exit 0
grep -q "$ticket" "$msg_file" || sed -i "1s/^/$ticket: /" "$msg_file"

That case guard matters. Without it the hook mangles merge messages and interferes with --amend.

Terminal window
git commit --no-verify -m "feat(cfg): add config"
git push --no-verify

--no-verify skips pre-commit, commit-msg and pre-push. It exists deliberately: hooks are a convenience, and a developer must be able to commit when a hook is broken or irrelevant.

That is precisely why hooks are not enforcement.

ConcernLocal hookServer-side rule
Fast feedbackYesNo
Works offlineYesNo
BypassableYes, with --no-verifyNo
Applies to everyoneOnly if installedYes
Suitable for policyNoYes

Use hooks for speed. Use server-side rules for anything that must actually hold.

Writing and distributing hooks by hand works, and at team scale most projects reach for a manager instead. They exist to solve three problems the raw mechanism does not: installing the hooks on every clone, declaring which tools each hook needs, and running several checks per hook without one long script.

The common pattern is a committed configuration file plus a one-time install step:

# .pre-commit-config.yaml — illustrative
repos:
- repo: local
hooks:
- id: format
name: Format code
entry: ruff format
language: system
types: [python]
Terminal window
pre-commit install # writes .git/hooks/pre-commit pointing at the tool

Whichever tool you pick, the trade-offs are the same:

What you gain. Declarative configuration in the repository, dependency management, a way to run several checks per hook, and usually parallel execution and file filtering.

What you pay. Another dependency to install, a layer between you and Git, and — critically — the install step is still per clone. No tool can change the fact that .git/hooks is not cloned.

The same check can often live in either place. A rough guide:

CheckHookCIWhy
FormattingInstant locally; CI catches anyone without hooks
Linting changed filesFast enough locally to be worth it
Commit message formatOnly a hook can catch it before the commit exists
Full test suiteToo slow for a hook; people will bypass it
Secret detectionHook prevents the commit; CI is the real gate
Branch protectionMust be server-side to mean anything
Dependency auditSlow, and needs network

The pattern that works: the same check in both places, fast locally and authoritative in CI. The hook gives you the two-second feedback loop; CI ensures the check actually happened.

A check that exists only as a hook is a check that some people run.

The hook is not running. Check the exact filename — no .sample — and the executable bit:

Terminal window
ls -l .git/hooks/pre-commit

Then check whether core.hooksPath is redirecting Git elsewhere:

Terminal window
git config --get core.hooksPath

It fails with “command not found”. The hook runs with a limited environment, not your interactive shell. PATH may lack directories your shell profile adds. Use absolute paths or set PATH explicitly.

Nothing appears in the output. Write to stderr, not stdout.

It behaves oddly in a worktree. In a linked worktree, git rev-parse --git-dir returns that worktree’s private directory. For the shared repository, use:

Terminal window
git rev-parse --git-common-dir

Test it without committing:

Terminal window
.git/hooks/pre-commit; echo "exit: $?"
echo "feat: test message" > /tmp/msg && .git/hooks/commit-msg /tmp/msg

Relying on hooks for security policy. Bypassable and optional. Use server-side rules.

Forgetting chmod +x. The single most common reason a hook silently does nothing.

Leaving the .sample suffix.

Making hooks slow. A pre-commit taking thirty seconds trains people to use --no-verify, which disables all your hooks.

Reformatting files inside a hook. Produces commits containing unreviewed content.

Assuming teammates have them. They are not cloned.

Writing errors to stdout. May not be seen.

Checking the working tree instead of the index. pre-commit should examine git diff --cached.

Using --git-dir in a worktree-aware hook. Use --git-common-dir.

A hook is a doorbell, not a lock.

It tells you something is about to happen and gives you a chance to object. It runs on your machine, at your invitation, and you can silence it whenever you like.

Locks live on the server. If it must not happen, the server has to refuse it.

  • A hook is an executable in .git/hooks with an exact name; non-zero exit aborts “pre” operations.
  • .sample files are inert; renaming and chmod +x activates them.
  • Hooks are never cloned, which is both a limitation and a security property.
  • pre-commit, commit-msg and pre-push are the three that catch most problems.
  • core.hooksPath points Git at a committed directory so hooks can be shared — but the setting itself is per clone.
  • --no-verify bypasses client-side hooks, which is why they are not enforcement.
  • Server-side hooks and branch protection rules are the enforcement mechanism.
  • Write messages to stderr, check the index rather than the working tree, and keep hooks fast.
  1. Create a repository:

    Terminal window
    mkdir ~/hooks-lab && cd ~/hooks-lab && git init
  2. Add a commit-msg hook:

    cat > .git/hooks/commit-msg <<'EOF'
    #!/bin/sh
    pattern='^(feat|fix|docs|refactor|test|chore)(\([a-z0-9-]+\))?: .{1,}'
    if ! grep -qE "$pattern" "$1"; then
    echo "commit-msg: subject must look like 'feat(parser): add support for X'" >&2
    exit 1
    fi
    EOF
    chmod +x .git/hooks/commit-msg
  3. Test the failure path:

    Terminal window
    echo x > f.txt && git add . && git commit -m "bad message"

    Predict: does the commit happen?

  4. Test the success path: git commit -m "feat(parser): add tokeniser".

  5. Forget the executable bit deliberately: chmod -x .git/hooks/commit-msg, then commit with a bad message again. Observe that it succeeds — this is the failure mode you will hit for real one day.

  6. Restore it and try git commit --no-verify -m "bad message". Observe the bypass.

  7. Share it properly:

    Terminal window
    mkdir .githooks && cp .git/hooks/commit-msg .githooks/
    chmod +x .githooks/commit-msg
    git config core.hooksPath .githooks
    rm .git/hooks/commit-msg
    git add .githooks && git commit -m "chore: add shared hook"

    Confirm it still runs from the new location.

Steps 5 and 6 are the two that matter. A hook that silently does nothing, and a hook anyone can skip, are exactly why hooks are convenience rather than control.

Aliases are the other half of local ergonomics: making the commands you run constantly shorter and clearer.