Git, CI/CD & GitOpsXXV · HooksHooks
What Git hooks are — scripts Git invokes at lifecycle events
What you'll learn
- Define a Git hook as an executable script Git invokes at a named lifecycle event
- Distinguish client-side hooks (developer machine) from server-side hooks (receiving server)
- Identify the lifecycle points at which Git invokes a hook (commit, push, receive, etc.)
- Recognise why hooks are the canonical integration point for lint, format, secret-scan, and policy
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
A Git hook is an executable script that Git invokes at a named lifecycle event. The lifecycle events are the points at which Git does something interesting - recording a commit, accepting a push, updating a ref, finishing a merge - and the hook is the slot Git gives the user to inject behaviour at that point. Every other Git-integrated tool that runs in response to a commit or a push (pre-commit frameworks, lint-staged, secret scanners, branch- protection rules) is, at the bottom of the stack, a hook script that Git invokes and whose exit code Git interprets as accept or refuse.
The lifecycle-event model
Git’s hook system is a list of named events. Each event has a fixed place in a workflow, a fixed set of arguments, and a fixed contract for what the hook’s exit code means:
flowchart LR
C["git commit"] --> PC["pre-commit hook"]
PC -->|"exit 0"| CMT["record commit object"]
PC -->|"exit non-zero"| AB["commit aborted"]
CMT --> CM["commit-msg hook"]
CM --> P["git push"]
P --> PP["pre-push hook"]
PP -->|"exit 0"| XMIT["transmit pack to remote"]
PP -->|"exit non-zero"| PAB["push aborted"]
XMIT --> PR["pre-receive hook on server"]
PR -->|"exit 0"| UPD["ref-update protocol"]
PR -->|"exit non-zero"| RAB["push refused"]
The contract is consistent across every hook: an exit code of 0
means accept (proceed), and any non-zero exit code means refuse
(abort the operation, print the hook’s stderr to the user). The
hook has access to the same arguments Git would have used, plus a
set of environment variables, plus any stdin the hook reads. The
hook can also modify the working tree, the index, or the
in-progress refs in ways that affect what Git does next.
Client-side versus server-side
Hooks are split by where they run, not by what they do:
- Client-side hooks run on the developer’s machine, in
.git/hooks/. They fire before the commit is recorded (pre-commit,commit-msg,prepare-commit-msg), after the commit is recorded (post-commit), before the push leaves the machine (pre-push), after the push completes (post-push), and around merges and checkouts (pre-merge-commit,post-merge,post-checkout). - Server-side hooks run on the receiving server, in the bare
repository’s
hooks/directory. They fire before any ref update (pre-receive), per-ref (update), and after all refs have been updated (post-receive).
ls .git/hooks/
# applypatch-msg.sample
# commit-msg.sample
# pre-commit.sample
# pre-push.sample
# prepare-commit-msg.sample
# post-update.sample
# ...
The split is structural, not stylistic. Client-side hooks are a developer convenience: they catch problems before they leave the machine, but the engineer who installed the hook is the only person who runs it. Server-side hooks are the only mechanism that enforces policy on push, because the server runs the hook on every push, regardless of which client made the push.
Why hooks exist
Hooks exist because Git is content-addressed and stateless. The commit object, once written, is identified by its SHA and cannot be modified without changing its identity. The push, once sent, is a pack of objects that the server either accepts or rejects. The places where Git can ask “should I proceed?” are therefore finite: before writing the commit, before sending the pack, before updating the ref on the server. Hooks are the slots at those places.
Every other Git-integrated workflow tool is a wrapper around
these slots. The pre-commit framework is a Python application
that manages a .pre-commit-config.yaml and installs scripts into
.git/hooks/pre-commit. Lint-staged is a Node tool that runs
linters on staged files from inside pre-commit. GitHub branch
protection is a hosted pre-receive hook. GitLab push rules are
a hosted pre-receive hook. The pre-receive hook in Part XXII’s
incident response that requires --force-with-lease is the
same mechanism, written by hand.
Hooks versus webhooks
A common source of confusion is the difference between a Git hook (a script Git invokes locally or on the server) and a Git host webhook (an HTTP callback the host sends to a configured URL when an event happens on the host). The two are analogous in spirit but different in mechanism:
- A Git hook runs inside the Git process and can refuse an operation (exit non-zero aborts the commit, the push, or the receive). It is invoked by Git itself.
- A webhook is an outbound HTTP POST sent by the Git host after the event has already happened. It cannot refuse anything. It is invoked by the host, not by Git.
The two compose: a post-receive hook can curl a webhook URL
to notify an external system that the push completed; the
external system receives the notification but cannot reject the
push that has already been accepted by the host.
Production discipline
- Treat every client-side hook as bypassable. The
--no-verifyflag ongit commitandgit pushskips every hook Git would have invoked at that step. Plan for the bypass; do not plan for the hook being unbreakable. - Put policy on server-side hooks. Every rule that must
hold for every engineer (no secrets, signed commits only,
no force-pushes to protected branches) belongs on the
server’s
pre-receivehook or the host’s hosted equivalent. The server is the only place that runs the hook on every push. - Store hook scripts in version control. The contents of
.git/hooks/are not committed (the directory is ignored), so a hook installed by hand is a hook that cannot be reproduced. Keep the script underscripts/hooks/and copy it ongit initor as part of developer onboarding. - Audit hook changes. A modified hook is a modified
policy. Track changes to
scripts/hooks/in the same pull- request review process as changes to the code the hooks protect.
Cross-course references
- Git, CI/CD & GitOps - Part II (GitArch) lesson 04 covers
the
.git/directory layout; thehooks/directory is part of that layout. - Git, CI/CD & GitOps - Part VI (Staging) lesson 05 explains how the index is the canonical input to a pre-commit hook; the index is what the hook reads, not the working tree.
- CI/CD Pipeline Patterns - Parts II (PipelineStages) and
VIII (BranchPolicies) cover hosted equivalents of
pre-receive(branch protection, required status checks).
Quiz
Knowledge check · 4 questions
Q1. An engineer runs `git commit --no-verify -m 'fix'`. What is the practical effect of `--no-verify` on the hook chain?
Q2. A server-side hook such as `pre-receive` runs on every push regardless of which client made the push, while a client-side hook such as `pre-commit` only runs for the engineer who installed it.
Q3. Name the structural difference between a Git hook and a Git host webhook, and explain what each can and cannot do.
Q4. Diagnose why a team relying on a pre-commit hook still leaks secrets to the remote, and recommend the server-side control that would catch the leak.
A team has a pre-commit hook that scans staged files for AWS access key patterns and refuses to record the commit if a match is found. An engineer installs the hook, then runs `git commit --no-verify -m 'wip'` after pasting a key into a Terraform file by accident. The commit is recorded, the engineer pushes it, and the key lands in the remote. The team asks why the pre-commit hook did not catch the leak.
Passing score: 75%. Answers are checked in this browser.