---
title: Hooks
source: 'https://academia.sh/en/courses/advanced-git/hooks'
course: 'Advanced Git'
language: en
updated: '2026-08-17T18:10:42+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```text
# 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

```python
"""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.
