Git Hooks: Automate Your Git Workflow
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.
How they work
Section titled “How they work”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.
mv .git/hooks/pre-commit.sample .git/hooks/pre-commitchmod +x .git/hooks/pre-commitTwo 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.
The hooks worth knowing
Section titled “The hooks worth knowing”| Hook | Runs | Can abort? | Typical use |
|---|---|---|---|
pre-commit | Before the message is requested | Yes | Lint, format, detect secrets |
prepare-commit-msg | Before the editor opens | Yes | Insert a template or ticket reference |
commit-msg | After the message is written | Yes | Validate message format |
post-commit | After the commit exists | No | Notifications |
pre-push | Before objects are sent | Yes | Run tests, block protected branches |
post-merge | After a merge completes | No | Reinstall dependencies |
post-checkout | After checkout or switch | No | Rebuild generated files |
pre-rebase | Before a rebase | Yes | Protect 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.
pre-commit: catching problems early
Section titled “pre-commit: catching problems early”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 1fichmod +x .git/hooks/pre-commitgit add cfg.pygit commit -m "feat(cfg): add config"cfg.pypre-commit: possible credential detected in staged changesThe commit does not happen. Three details in that script matter:
--cachedexamines what is staged, not the working tree. You are checking what is about to be committed.-zwithxargs -0handles filenames containing spaces.-Ion grep skips binary files, avoiding noise.
Linting only what changed
Section titled “Linting only what changed”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 1fi--diff-filter=ACM selects added, copied and modified files, skipping deletions — a deleted file cannot be
linted.
commit-msg: validating the message
Section titled “commit-msg: validating the message”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 1figit commit -m "bad message"commit-msg: subject must look like 'feat(parser): add support for X'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.
pre-push: the last local gate
Section titled “pre-push: the last local gate”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 ;; esacdoneexit 0Running 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.
Sharing hooks with a team
Section titled “Sharing hooks with a team”Since .git/hooks is not cloned, sharing requires pointing Git somewhere that is.
git config core.hooksPath .githooksWhat 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.
-
Create the directory and commit the hooks:
Terminal window mkdir .githooksmv .git/hooks/pre-commit .githooks/chmod +x .githooks/pre-commitgit add .githooks && git commit -m "chore: add shared pre-commit hook" -
Point Git at it:
Terminal window git config core.hooksPath .githooks -
Document the step, because it is per clone —
core.hooksPathis 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:
#!/bin/shgit config core.hooksPath .githooksecho "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.
Hooks in other languages
Section titled “Hooks in other languages”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.
Hooks that run after the fact
Section titled “Hooks that run after the fact”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 ;;esacThis 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 0echo "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 IDmsg_file=$1source=$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.
Bypassing
Section titled “Bypassing”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.
| Concern | Local hook | Server-side rule |
|---|---|---|
| Fast feedback | Yes | No |
| Works offline | Yes | No |
| Bypassable | Yes, with --no-verify | No |
| Applies to everyone | Only if installed | Yes |
| Suitable for policy | No | Yes |
Use hooks for speed. Use server-side rules for anything that must actually hold.
Hook management tools
Section titled “Hook management tools”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 — illustrativerepos: - repo: local hooks: - id: format name: Format code entry: ruff format language: system types: [python]pre-commit install # writes .git/hooks/pre-commit pointing at the toolWhichever 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.
Choosing between a hook and CI
Section titled “Choosing between a hook and CI”The same check can often live in either place. A rough guide:
| Check | Hook | CI | Why |
|---|---|---|---|
| Formatting | ✔ | ✔ | Instant locally; CI catches anyone without hooks |
| Linting changed files | ✔ | ✔ | Fast enough locally to be worth it |
| Commit message format | ✔ | ✔ | Only a hook can catch it before the commit exists |
| Full test suite | ✖ | ✔ | Too slow for a hook; people will bypass it |
| Secret detection | ✔ | ✔ | Hook prevents the commit; CI is the real gate |
| Branch protection | ✖ | ✔ | Must be server-side to mean anything |
| Dependency audit | ✖ | ✔ | Slow, 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.
Debugging
Section titled “Debugging”The hook is not running. Check the exact filename — no .sample — and the executable bit:
ls -l .git/hooks/pre-commitThen check whether core.hooksPath is redirecting Git elsewhere:
git config --get core.hooksPathIt 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:
git rev-parse --git-common-dirTest it without committing:
.git/hooks/pre-commit; echo "exit: $?"echo "feat: test message" > /tmp/msg && .git/hooks/commit-msg /tmp/msgCommon mistakes
Section titled “Common mistakes”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.
Mental Model
Section titled “Mental Model”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.
What You Learned
Section titled “What You Learned”- A hook is an executable in
.git/hookswith an exact name; non-zero exit aborts “pre” operations. .samplefiles are inert; renaming andchmod +xactivates them.- Hooks are never cloned, which is both a limitation and a security property.
pre-commit,commit-msgandpre-pushare the three that catch most problems.core.hooksPathpoints Git at a committed directory so hooks can be shared — but the setting itself is per clone.--no-verifybypasses 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.
Try It Yourself
Section titled “Try It Yourself”-
Create a repository:
Terminal window mkdir ~/hooks-lab && cd ~/hooks-lab && git init -
Add a
commit-msghook:cat > .git/hooks/commit-msg <<'EOF'#!/bin/shpattern='^(feat|fix|docs|refactor|test|chore)(\([a-z0-9-]+\))?: .{1,}'if ! grep -qE "$pattern" "$1"; thenecho "commit-msg: subject must look like 'feat(parser): add support for X'" >&2exit 1fiEOFchmod +x .git/hooks/commit-msg -
Test the failure path:
Terminal window echo x > f.txt && git add . && git commit -m "bad message"Predict: does the commit happen?
-
Test the success path:
git commit -m "feat(parser): add tokeniser". -
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. -
Restore it and try
git commit --no-verify -m "bad message". Observe the bypass. -
Share it properly:
Terminal window mkdir .githooks && cp .git/hooks/commit-msg .githooks/chmod +x .githooks/commit-msggit config core.hooksPath .githooksrm .git/hooks/commit-msggit 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.
Next Lesson
Section titled “Next Lesson”Aliases are the other half of local ergonomics: making the commands you run constantly shorter and clearer.