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 +xstops the hook running and lets the operation through; Git 2.16+ warns in ahint:line that is easy to miss, so check the bit first, every time - Exit
0allows the operation, non-zero blocks it; printed output is your user interface pre-commit,commit-msgandpre-pushare 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