Great work!

XP to next level

BugEater
EN

What a Hook Is and Where It Lives

Learning Objectives

By the end of this lesson you will be able to:

  • Explain when Git runs a hook and how it decides to
  • Find the hooks directory and the sample hooks Git ships
  • Name the hooks worth knowing and what each one can block
  • State the three limits that make hooks an assistant, not a gate

The Mechanism

A hook is an executable file in .git/hooks/ with a specific name. At certain moments, Git looks for a file with that name and, if it is there and executable, runs it.

That is all. No plugin system, no configuration, no registry. A file with the right name in the right place.

ls .git/hooks/
applypatch-msg.sample     pre-commit.sample
commit-msg.sample         pre-push.sample
post-update.sample        pre-rebase.sample
prepare-commit-msg.sample update.sample

Every repository starts with these samples. They are inert because of the .sample extension — Git looks for pre-commit, not pre-commit.sample. Renaming one activates it, and reading them is a good way to see the shape of a real hook.

The Two Requirements

A hook that "does nothing" almost always fails one of these.

1. The exact filename, with no extension. pre-commit, not pre-commit.sh, not precommit.

2. The executable bit.

chmod +x .git/hooks/pre-commit

Without it, Git does not run the file — and the operation you were trying to guard succeeds anyway. Since Git 2.16 you at least get told:

hint: The '.git/hooks/pre-commit' hook was ignored because it's not set as executable.
hint: You can disable this warning with `git config advice.ignoredHook false`.

Two dim lines above a commit that worked. That is easy to scroll straight past, which is why this is still the single most common "my hook isn't working" cause and still costs people twenty minutes every time. On anything older than 2.16 there is no warning at all — the hook simply never runs.

The language does not matter. Any executable works — bash, Python, Node — as long as it starts with an appropriate shebang:

#!/usr/bin/env bash
#!/usr/bin/env python3

Blocking Hooks and Their Exit Codes

Some hooks run before an action and can prevent it. The rule is the one from the bisect module:

  • Exit 0 — allow the operation to proceed
  • Exit non-zero — abort it
#!/usr/bin/env bash
if grep -rn "console.log" src/; then
  echo "❌ console.log found in src/ — remove it before committing."
  exit 1
fi

Anything the hook prints goes straight to the developer's terminal, so the message is your entire user interface. Say what is wrong, where, and what to do about it. exit 1 with no output is a hostile experience.

The Hooks Worth Knowing

There are around twenty. Four matter for QA work.

Hook Runs Can block Typical use
pre-commit Before the commit message is requested Yes Linters, formatters, fast unit tests, secret scanning
prepare-commit-msg Before the editor opens No Pre-fill the message with the ticket ID from the branch name
commit-msg After the message is written Yes Enforce a message convention — Conventional Commits, a ticket reference
pre-push Before commits are sent Yes Test suite, build check, blocking pushes to main

The rest are mostly server-side (pre-receive, update, post-receive) or specialised (post-checkout, pre-rebase).

A commit-msg hook receives the path to the message file as its first argument, which makes it very short:

#!/usr/bin/env bash
grep -qE '^(feat|fix|docs|test|chore)(\(.+\))?: .{1,50}$' "$1" && exit 0
echo "❌ Commit message must be 'type(scope): summary', 50 chars or fewer."
exit 1

The Three Limits

Be clear about these, because they decide what a hook can be trusted for.

Hooks are local. .git/hooks/ lives inside .git/, which is not part of the repository's content. Nothing you put there is committed, and nothing you put there reaches a colleague. Sharing them is a separate problem, and it is Lesson 4.

Hooks are not committed. The same point from the other direction: cloning a repository gives you the sample hooks and nothing else, no matter what the team has configured on their machines.

Hooks can be bypassed.

git commit --no-verify
git push --no-verify

--no-verify skips pre-commit, commit-msg and pre-push entirely. There is no way to prevent it, and there should not be — a developer sometimes genuinely needs to commit broken work in progress.

Together these mean: a hook is a fast local assistant, never the authority. The authority is CI, which runs on a server nobody can --no-verify. A hook's job is to catch a mistake in two seconds so CI never has to catch it in fifteen minutes.

Pro Tip: Before writing any hook, run it as a plain script by hand and check echo $?. Almost every hook problem is either the executable bit or an exit code you did not expect, and both are visible in five seconds outside of Git.

Key Takeaways

  • A hook is an executable file in .git/hooks/ with a specific name — no extension
  • Missing chmod +x stops the hook running and lets the operation through; Git 2.16+ warns in a hint: line that is easy to miss, so check the bit first, every time
  • Exit 0 allows the operation, non-zero blocks it; printed output is your user interface
  • pre-commit, commit-msg and pre-push are the three worth writing
  • Hooks are local, never committed, and bypassable with --no-verify
  • They are an assistant for speed, not a gate — CI remains the real gate

Quiz

Your pre-commit hook exists but never runs. What is the most likely cause?

What does a hook's exit code control?

Which hook enforces a commit message convention?

Why is a pre-push hook not a substitute for CI?