Lesson 07 / 12
Hooks
A hook is an event point, and the moment the block is set determines the cost: a change stopped before commit touches zero objects, before push ten commits and 140 objects, six hundred commits after acceptance 601 commits, 3522 objects, and all seven of the seven copies.
Contents
The previous two lessons asked history a question after the fact: the bug had already been introduced, the line had already been written. The tools read a record that had already happened and paid the cost on the reading side.
A change, however, does not enter history in one step. It first sits in the working area, then moves to the staging area, then becomes a commit, then gets pushed, then the other side accepts it. Each of these moments is a gate, and a check can be placed at any gate. A hook is the program the tool runs at one of these moments; if its exit code is nonzero, the operation stops at some gates. This lesson’s question is not what a hook does: it is how cost changes depending on which gate a block is placed at, and who pays that cost.
A Hook Is an Event Point
A hook is nothing more than an executable file sitting under a fixed name inside the repository’s own directory. When the tool reaches a specific moment of a specific operation, it looks for a file at that name, runs it if present, and reads the exit code. There is no plugin mechanism, no registry, no configuration language behind this; the contract is only the file’s name, its being executable, and its exit code.
The use of hooks to trigger a continuous integration pipeline was built in the Continuous Integration and Delivery course, where a hook was a trigger and what was discussed was the pipeline itself. That discussion is not repeated here. In this lesson, a hook is a local event point, and the only thing measured is which commit can still be stopped at that point.
There are two classes, and the difference between them determines the lesson’s conclusion. Client-side hooks run in the developer’s own repository, at the moments before a commit and before a push. Server-side hooks run on the other side, before pushed changes are accepted. The first does not travel in the repository’s history — cloning does not bring hooks along — the second always runs, independent of the repository’s history.
The Order of the Gates
The moments and the hooks that run at them follow the order below. Only some of them carry the authority to block; the rest report after the fact.
# taught file names and example dump — not executed
pre-commit before the commit is written — can block
commit-msg after the message is written — can block
post-commit after the commit is written — cannot block
pre-push before the push starts — can block
pre-receive before the other side accepts — can block
update separately for each ref — can block
post-receive after acceptance completes — cannot block
# .git/hooks/pre-commit
#!/bin/sh
if git diff --cached --name-only | grep -q '\.bin$'
then
echo "binary asset in staging area; commit blocked" >&2
exit 1
fi
exit 0
# example dump
$ git commit -m "presentation refreshed"
binary asset in staging area; commit blocked
Two distinctions follow. post- prefixed hooks cannot change a decision; the work is
already done and the hook only reports. And every blocking hook on the client side can be
skipped with an option given to the command — this is not a flaw but a design choice: a
local hook exists for the developer’s own convenience, not as an enforcement mechanism.
The measurement’s assumptions:
- DT33 — History is generated from the shared fixture and is a thousand commits; the fixture and the oracle are not changed.
- DT34 — The change meant to be blocked is at commit 400. A single change is tracked; how many changes are blocked is not counted.
- DT35 — The unit of cost is a touched object. Removing a commit from history changes the ID of every commit after it; touched objects are the sum of the objects carried by the commits whose IDs change.
- DT36 — Pushes happen in batches of ten. The before-push and server-side hooks therefore fire nine commits later.
- DT37 — Being noticed after acceptance is measured at two points: 100 commits later and 600 commits later.
- DT38 — The repository has seven copies. An unpublished change concerns no copy; undoing a published change concerns all of them.
- DT39 — In the second part, the only thing that changes is scale. The number of times the hook runs and the number of commits it inspects are counted separately; the hook’s own duration is not measured.
Measurement
"""The moment of the hook: which commit can still be blocked at that moment. Part 1 - the cost of undoing when the gate is placed at five separate moments. Part 2 - the hook's own cost: how many times it runs, how many commits it inspects. """ SEED = 20260814 FILES = ("metrics.py", "report.py", "identity.py", "config.py", "document.md") COPIES, POSITION, BATCH = 7, 400, 10 def rng(seed): d = seed % 2147483646 + 1 def r(n): nonlocal d d = (d * 48271) % 2147483647 return d % n return r def history(n, seed=SEED): """n-commit linear history; each commit touches one file.""" r, log = rng(seed), [] for i in range(n): file = FILES[r(5)] binary = r(11) == 0 log.append({"no": i + 1, "file": file, "binary": binary, "object": 2 + (40 if binary else 0)}) return log def rewrite(t, position, noticed_at): """The set to be rewritten if the commit at position is noticed at noticed_at.""" if noticed_at is None: return 0, 0 between = [x for x in t if position <= x["no"] <= noticed_at] return len(between), sum(x["object"] for x in between) def resyncing(copy_count, position, t, noticed_at): """Who has to resync the rewritten history.""" changed, _ = rewrite(t, position, noticed_at) return copy_count if changed else 0 EVENTS = ( ("before commit", "yes", None, 0), ("before push", "yes", POSITION + BATCH - 1, 0), ("at server acceptance", "yes", POSITION + BATCH - 1, 0), ("100 commits after acceptance", "no", POSITION + 100, COPIES), ("600 commits after acceptance", "no", POSITION + 600, COPIES), ) t = history(1000) print(f"{'point the block is set':>28s} {'can block':>10s} " f"{'rewritten':>10s} {'objects touched':>16s} {'resyncing copies':>17s}") for name, blocks, noticed_at, copies in EVENTS: commits, objects = rewrite(t, POSITION, noticed_at) print(f"{name:>28s} {blocks:>10s} {commits:10d} {objects:16d} " f"{resyncing(copies, POSITION, t, noticed_at):17d}") print() print(f"{'commits':>7s} {'before commit: runs':>20s} {'before push: runs':>18s} " f"{'commits inspected':>18s}") for n in (50, 200, 1000, 4000): print(f"{n:7d} {n:20d} {n // BATCH:18d} {n:18d}")
point the block is set can block rewritten objects touched resyncing copies
before commit yes 0 0 0
before push yes 10 140 0
at server acceptance yes 10 140 0
100 commits after acceptance no 101 522 7
600 commits after acceptance no 601 3522 7
commits before commit: runs before push: runs commits inspected
50 50 5 50
200 200 20 200
1000 1000 100 1000
4000 4000 400 4000
The Moment the Block Is Set
The first row of the top table is the measurement’s anchor. A change stopped before the commit is written never enters history at all: rewritten commits 0, touched objects 0, copies that must resync 0. The change is still in the working area, and fixing it is nothing more than editing a file.
In the second and third rows, the commit has been written and nine more commits have piled on top. Undoing it costs 10 commits and 140 touched objects. Since it is not yet published, resyncing copies stays at 0; the cost sits entirely on the writer, and history can be rewritten on their own machine.
In the fourth and fifth rows, the gate has already closed. Noticed a hundred commits later, undoing changes the ID of 101 commits and touches 522 objects; six hundred commits later, 601 commits and 3522 objects. And resyncing copies is no longer 0 but 7. The jump here is not in the objects column but in the rightmost one: as touched objects grow twenty-five times, who does the work shifts from one person to all seven copies.
The measurement’s resolution in the thousand-commit set is 1/1000; the smallest distinguishable difference is a single commit. The 10-commit gap between the first and second rows is ten times this band and is carried comfortably. The 500-commit gap between the fourth and fifth rows is half the scale; the one thing this resolution cannot defend is drawing fine distinctions of a few commits between gates.
The pattern is this: the cost of a block grows not with which moment it is placed at but with how long it was not placed. The gap between gates is a few seconds or a few hours; the gap between costs runs from zero to three thousand five hundred twenty-two. And past a certain point, this is no longer a cost the writer alone can pay.
Why After Acceptance Is a Separate Class
Removing an accepted commit from history is rewriting history, and that operation was established with its own cost in this course’s first topic: it is not reversible, it requires every copy to resync, and its command is never written in full. The only thing added here is why that cost was incurred: the block was set at a gate too late.
A published error has an answer without rewriting history too: adding a new commit that reverts the change. Revert does not break history, does not change IDs, does not force copies to resync — it is the safe path and is tried first. But revert fixes the record; it does not delete the object. A leaked secret or a large asset added by mistake keeps standing among history’s objects even after a revert, and keeps going to everyone who clones.
This is where the server-side hook’s real justification comes from. A leaked secret is not recovered by fixing the repository but by invalidating the value itself; cleaning up history is only a second task that comes after that. The acceptance gate is the last moment at which neither of these two tasks is yet required.
The Hook’s Own Cost
The bottom table shows that placing the block early is not free. In a four-thousand-commit history, the before-commit hook runs 4000 times; the before-push hook, because of batches of ten, runs 400 times. The number of commits inspected is 4000 in both cases — the same work, split across a tenfold-different number of runs.
The cost of this split is the fixed cost of every run: starting the process, loading the check tool, scanning the directory. Fixed cost multiplies by the number of runs, not the number of commits inspected; the early gate therefore pays ten times the fixed cost. What it earns in return is feedback distance: the before-commit hook stops a change the moment it is produced, the before-push hook at worst nine commits later.
Where the two gates stand in the measurement is now clear. An early gate means cheap fixes and expensive operation; a late gate means the reverse. The common arrangement splits the two: cheap checks that take seconds go on the before-commit gate, expensive checks that run long go on the before-push gate, and checks that require enforcement are repeated at the acceptance gate in either case.
What a Hook Can See
Which moment a hook runs at also determines what it can see. The before-commit hook sees the staging area’s contents, not the working area’s uncommitted changes. When this distinction is missed, a hook checks the wrong thing: a check that reads files from the working area also looks at unstaged lines in a partially staged change, and the staged content passes through without ever being checked while the commit goes through. The correct hook reads the staging area, not the file system.
The two hooks at the acceptance gate also differ in resolution. One runs once for the whole pushed batch and gives its decision to the entire batch: either all of it goes in or none of it does. The other runs separately for each ref and can reject one branch while accepting another. The measurement’s batches-of-ten assumption pays off here — a gate that rejects the whole batch turns away nine bug-free commits along with the one buggy commit. The nine rejected commits are not lost; they stay in the writer’s own history — but the batch has to be reorganized, and this is the second cost the acceptance gate charges beyond the early gates.
Two rules for the hook itself follow from this. A hook must be fast: a check that takes seconds on every run turns into hours across four thousand runs and eventually starts getting skipped. And a hook must be deterministic: a check that gives two different answers to the same content lets through as much as it blocks and carries no assurance at all.
Whose Hook Runs
The final distinction is the lesson’s conclusion. Client-side hooks do not travel in the repository’s history; cloning does not bring them, no commit can publish them. Every team member has to install them separately in their own repository, and whoever installs one can also skip it with an option. A client-side hook is therefore a check of unknown installation rate — it is not known in how many of the seven copies it is even set up.
There is a partial answer to this non-travel. Hooks can be written to an ordinary tracked directory inside the repository, and the tool’s hook directory can be pointed there with a setting; the hooks themselves then travel with history and the team shares the same check. But setting up that pointer is still a separate step on every copy, and skippability does not change. What travels is the hook’s content, not its enforcement — how many of the seven copies have it active is still unknown.
A server-side hook is the reverse: it sits in one place, runs the same way for everyone, and cannot be skipped. Every rule that requires enforcement eventually has to be written there. But the acceptance gate is also the last gate that can still block, and by the time it is reached, the commit has already been written, has already entered the writer’s own history, and perhaps nine more commits have already piled on top. The measurement’s third row shows exactly this: the acceptance gate can block, but even when it does, undoing it costs 10 commits and 140 objects.
The conclusion is that both of these are true at once: the one gate with enforcement is not the gate that zeroes the cost, and the gate that zeroes the cost has no enforcement. A client-side hook is therefore not an assurance but a convenience — a convenience that tells the writer, five hundred twenty-two objects early, something the acceptance gate would have rejected anyway.
Summary
- A hook is an event point: a program the tool runs at a specific moment and an exit code
it reads.
post-prefixed hooks report; they do not change a decision. - If the block is set before commit, the change never enters history: 0 commits, 0 touched objects. Set before push, it is 10 commits and 140 objects, with 0 copies since it is not yet published.
- Noticed six hundred commits after acceptance, undoing it touches 601 commits and 3522 objects, and all seven of seven copies must resync; the cost leaves the writer alone and spreads to everyone.
- Revert answers a published error without breaking history but does not delete the object; the answer to a leaked secret is first invalidating the value itself.
- The early gate runs 4000 times across four thousand commits, the push gate 400 times; commits inspected are 4000 in both. The early gate pays ten times the fixed cost, and in return brings feedback distance down from nine commits to zero.
- A client-side hook does not travel and can be skipped; a server-side hook cannot be skipped but is the latest gate; the gate with enforcement is not the gate that zeroes the cost.
Next Step
Throughout this topic, the object count always stayed in the background of the measurement: counting the search’s steps, counting blame’s scan, counting the cost of a hook set too late — in every case history sat inside a single repository, and the objects were that repository’s own. The next topic brings this background to the front: what if object count is not the measurement’s ground but the work itself? When a repository absorbs another repository’s history, or when every version of a single file accumulates as a separate object, the background we have counted as fixed up to now turns into the thing that has to be measured.
To keep your progress and take notes, Log in
My notes
Log in to take notes.