Git has had hooks for practically forever. We can stop a commit before it is created, validate a commit message, react after checkout, before push, after receiving changes on a server, or after a rebase.
The problem starts when we want to react not to a specific command, but to a change in repository state.
We want to know that a branch was created, deleted, or renamed. That a tag, stash, or remote-tracking branch changed. That HEAD became detached. Or that a worktree was created, moved, or removed.
Git does not provide most of these callbacks.
What it does provide is a much lower-level hook called reference-transaction, which observes transactions performed on references. git-hooks-ext uses exactly that mechanism and translates streams of ref updates into events that have meaning for humans and applications:
branch-created
branch-deleted
branch-updated
branch-renamed
tag-created
tag-deleted
tag-updated
remote-branch-created
remote-branch-updated
head-attached
head-detached
head-switched
Where Git does not expose even a sufficiently useful low-level event, as with the worktree lifecycle, the project wraps git worktree and observes the repository state before and after the operation.
The result is a layer that Git itself is missing: semantic callbacks on top of reference operations.
What hooks Git provides, and which ones it does not
Classic Git hooks are mostly tied to a specific workflow or command: pre-commit, commit-msg, post-commit, pre-rebase, post-merge, pre-push, post-checkout, post-rewrite, and others.
That works well if the question is:
Is the user currently making a commit?
It works much less well if the question is:
Did this particular part of repository state just change?
Git has no native branch-created, branch-deleted, branch-updated, or branch-renamed. There are no equivalent callbacks for tags, stashes, notes, replace refs, remote-tracking branches, or most special refs/* namespaces.
post-checkout looks like a partial solution for HEAD, but it is tied to checkout and switch, not to arbitrary HEAD changes.
On the server side, pre-receive and post-receive receive:
<old-oid> <new-oid> <ref-name>
but they are tied to receive-pack, so they are not general callbacks for local reference changes.
reference-transaction changes the perspective. It does not answer:
Which command did the user just run?
It is much closer to:
Which references did Git just change?
What are reference transactions?
A ref in Git is a name pointing to an object or to another reference.
refs/heads/main points to a commit. refs/tags/v1.0 may point to a commit or to a tag object. refs/remotes/origin/main represents a remote-tracking branch. refs/stash stores the current tip of the stash stack.
A branch is therefore not a special object of type "branch". From the reference subsystem's point of view, it is a name in a particular namespace. The same is true for tags and remote-tracking branches.
HEAD is a special case. Most of the time it is a symbolic reference:
HEAD -> refs/heads/main
In detached HEAD state, it points directly to a commit.
When Git changes one or more references, those operations can be performed as a reference transaction. The model is easy to see through:
git update-ref --stdin
where multiple ref updates can be prepared and then committed as one transaction.
The reference-transaction hook observes that layer. It receives the transaction state, including prepared, committed, and aborted, and in newer Git versions also preparing.
Via stdin it receives records in the form:
<old-value> <new-value> <ref-name>
For example:
0000000000000000000000000000000000000000 4fdc... refs/heads/topic
may look like a creation, while:
4fdc... 0000000000000000000000000000000000000000 refs/heads/topic
looks like a deletion.
Two non-zero values represent an update.
For symbolic refs, Git can also pass values such as:
ref:refs/heads/main
reference-transaction still does not say:
branch-created
It only provides low-level information about the ref transaction. Someone still has to assign meaning to that change.
Turning reference transactions into semantic hooks
The first step is identifying the namespace:
refs/heads/* -> branch
refs/remotes/* -> remote branch / remote HEAD
refs/tags/* -> tag
refs/stash -> stash
refs/notes/* -> note
refs/replace/* -> replace
refs/prefetch/* -> prefetch
refs/bisect/* -> bisect
refs/rewritten/* -> rewritten
refs/worktree/* -> worktree-specific ref
Unknown names below refs/* can still produce generic ref-created, ref-updated, and ref-deleted events. Refs outside refs/*, if they pass through the ref backend, can be classified as root-ref-*.
The second step is classifying the update itself.
At first glance, the rules look simple:
| old | new | semantics |
|---|---|---|
| zero | value | creation, or an update without a specific expected old value |
| value | zero | deletion |
| value A | value B | update |
The important complication is that an all-zero old value does not necessarily mean that the ref did not exist before the transaction.
Git can also use zero when an update does not require a particular previous value. In other words:
zero -> OID
can represent both a newly created ref and a ref that already existed but was updated without an old-value constraint.
That means the payload received at committed is not always enough to reconstruct the actual previous repository state.
The useful detail is that the previous state may still be available earlier.
During prepared, the transaction has not yet been committed, so a ref whose old value arrived as zero can still be inspected.
git-hooks-ext uses that phase to recover the previous value before it disappears.
If a ref has an all-zero old value, the bridge asks Git for its current state and records whether it exists, whether it is symbolic, and what it points to. It uses Git's own commands rather than reading loose ref files directly, so it does not assume that the repository uses the files backend instead of reftable.
The temporary state is stored in a private git-hooks-ext-state directory inside the Git administrative directory, resolved through:
git rev-parse --git-path git-hooks-ext-state
At committed, the bridge matches the snapshot to the transaction and restores missing old values before classifying the changes.
At aborted, the snapshot is discarded.
That makes the semantic distinction much stronger.
For example:
zero -> OID
refs/heads/topic
can be classified as branch-created if refs/heads/topic really did not exist before the transaction.
If the ref did exist and pointed somewhere else, the same low-level shape can instead be classified as an update.
The difference matters in real workflows.
Consider:
git stash push
The first stash creates refs/stash.
A later:
git stash push
updates the existing ref.
If Git reports:
zero -> new-OID refs/stash
the committed payload alone cannot tell those cases apart. By preserving the previous state during prepared, the bridge can correctly emit:
stash-updated
for the second operation rather than treating it as another creation.
The same recovery helps with operations such as:
git notes append
git notes remove
Removing a note changes the notes ref; it does not necessarily delete that ref. With the real previous value available, the operation can be classified as note-updated instead of being mistaken for a creation or deletion.
The principle is simple:
Observe the facts while they still exist, but react only after the transaction commits.
Rename
Rename is more interesting because reference-transaction has no rename operation.
If we see:
OID A -> zero refs/heads/old
zero -> OID A refs/heads/new
then git-hooks-ext can interpret that as branch-renamed, but only when the match is unambiguous.
This matters because deleting one branch and creating another at the same commit may look exactly like renaming one branch to the other at the ref-transaction level.
For example:
OID A -> zero refs/heads/old
zero -> OID A refs/heads/new
could correspond to:
git branch -m old new
but it could also represent two logically independent operations:
delete old
create new at the same commit
reference-transaction describes state changes, not user intent.
There is no field saying:
operation=rename
That is why rename detection is deliberately best-effort.
There is another ambiguity as well. Suppose the transaction contains:
OID A -> zero refs/heads/foo
OID A -> zero refs/heads/bar
zero -> OID A refs/heads/baz
zero -> OID A refs/heads/qux
Which branch was renamed to which?
Was it:
foo -> baz
bar -> qux
or:
foo -> qux
bar -> baz
The ref transaction itself cannot tell us.
A semantic layer should not invent an answer when multiple interpretations fit the same facts.
HEAD
HEAD allows a few more semantic events.
A transition from a direct OID to:
ref:refs/heads/main
can be recognized as:
head-attached
The inverse can become:
head-detached
A symbolic transition such as:
ref:refs/heads/main
->
ref:refs/heads/topic
can become:
head-switched
Every observable value change of HEAD also emits:
head-updated
The prepared-phase snapshot is useful here as well.
If Git reports the old value as zero, the bridge can preserve whether the previous HEAD value was symbolic or direct before the transaction commits.
That makes ordinary detach operations such as:
git checkout --detach
observable even when the committed payload alone would not contain enough information to classify the transition.
Attachment and symbolic target changes still depend on Git actually exposing the corresponding transactions. The same recovery mechanism also helps classify symbolic remote HEAD updates and deletions where those transactions are available.
The same rule applies here as everywhere else:
Missing values can sometimes be recovered. Missing refs cannot.
If Git reports a ref but omits its old value, the earlier state may still be observable.
If Git never reports the ref at all, git-hooks-ext does not guess that it existed.
Why semantic hooks are still post-factum
Even though git-hooks-ext observes some state during prepared, the semantic events are emitted only after committed.
prepared is used to collect facts.
It does not emit:
branch-created
before Git has actually created the branch.
Only after the transaction commits are the collected facts combined with the final payload and classified.
So branch-created means:
The committed transaction was classified as a branch creation from the information Git made observable.
It does not mean:
Git is about to create a branch.
The events describe repository state.
They are not another layer for rejecting Git transactions.
Why the state after the transaction matters too
Recovering the previous value is not always sufficient.
Some internal ref maintenance operations can produce records that look like deletions even though the logical ref still exists.
Packed-ref maintenance is one example.
That means a potential deletion should sometimes be confirmed after the transaction commits.
If the ref existed before the transaction, the payload resembles a deletion, but the ref still exists afterward, the bridge must not emit a false:
branch-deleted
or:
tag-deleted
This second check separates actual logical state changes from backend maintenance.
The same mechanism makes it possible to recover real deletions from commands such as:
git branch -D
git tag -d
git remote prune
even when Git reports the equivalent of:
zero -> zero
The bridge knows what the ref pointed to before the transaction and confirms that it is absent afterward.
No command wrapper is required for branch, tag, stash, notes, or checkout operations.
Worktree: where reference transactions stop being enough
A worktree can have its own HEAD, index, and worktree-specific refs, but a worktree itself is not a ref.
Consider:
git worktree add -b feature ../feature
Git may create refs/heads/feature, which can produce branch-created.
But that does not imply worktree-created: the same branch could have been created with:
git branch feature
and a detached worktree can be created without creating any branch at all:
git worktree add --detach ../experiment
The distinction becomes clearer for other operations.
git worktree remove can delete a worktree while leaving its branch unchanged.
git worktree move can change only the worktree path and administrative metadata.
git worktree lock, unlock, prune, and repair also operate on metadata that is not itself a ref.
Some git worktree commands can trigger ref transactions, but those transactions describe reference changes, not the lifecycle of the worktree itself.
The same distinction applies to refs/worktree/*.
Events such as:
worktree-ref-created
worktree-ref-updated
worktree-ref-deleted
concern worktree-specific references.
They do not mean:
worktree-created
worktree-removed
worktree-moved
The wrapper
Since there is no hook covering the worktree lifecycle, git-hooks-ext provides another observation point:
ghe worktree <command> ...
Before a mutating operation it records:
git worktree list --porcelain -z
then forwards the arguments to the real git worktree.
If Git succeeds, it reads the state again and derives events from the difference:
worktree-created
worktree-removed
worktree-moved
worktree-locked
worktree-unlocked
worktree-pruned
worktree-repaired
The event is based on an observed state change, not merely on the command that was invoked.
The trade-off is that:
ghe worktree remove ../feature
can generate:
worktree-removed
while:
git worktree remove ../feature
bypasses the wrapper entirely.
Unlike branch, tag, stash, notes, and checkout operations, the wrapper is genuinely necessary here because the worktree lifecycle cannot be reconstructed from reference transactions alone.
Git: bugs, RFCs, and the limits of observability
Even when an operation is fundamentally a reference change, Git does not always report it through reference-transaction in a way that allows its semantics to be reconstructed.
That is why the project has a separate compatibility matrix.
Real Git versions are built and executed, real commands are run, and their raw reference-transaction payload is inspected.
The matrix covers Git 2.27 through 2.55, both the files and reftable backends, and commands such as branch, tag, fetch, remote, notes, and stash.
Tests also cover concurrent transactions, aborted operations, corrupt snapshots, and storage failures.
The coverage gate checks 100% line, function, and region coverage.
This makes it possible to distinguish bugs in git-hooks-ext from cases where Git itself never provided enough information.
git branch -m
To detect:
git branch -m old new
as:
branch-renamed
both sides are needed:
OID -> zero refs/heads/old
zero -> OID refs/heads/new
In some Git and ref-backend combinations, Git reports the deletion of the old branch but not the corresponding creation of the destination ref.
With only:
OID -> zero refs/heads/old
there is no way to determine the new name.
The prepared-phase snapshot does not solve this.
A snapshot can recover the previous value of a ref that appears in the transaction.
It cannot recover the name of a destination ref that Git never reports.
That distinction is fundamental.
A missing value for a known ref can often be recovered.
A missing ref cannot be reconstructed without guessing.
I reported the issue together with a proposed fix:
git branch -m omits the destination ref from the reference-transaction hook
git branch -D and git tag -d
Deletion has a different shape.
For commands such as:
git branch -D
and:
git tag -d
some Git versions may report the equivalent of:
zero -> zero
which does not reveal the previous OID in the transaction payload itself.
But unlike the missing destination of a rename, that information still exists earlier.
During prepared, the ref can be inspected before it disappears.
The bridge records the previous value and, after committed, confirms that the ref is actually gone.
That makes it possible to emit the correct:
branch-deleted
or:
tag-deleted
even when the final payload is insufficient on its own.
Interestingly:
git update-ref -d refs/heads/topic
can provide enough information directly.
That shows that the limitation belongs to particular Git code paths rather than to the reference-transaction model itself.
Similar differences appear with remote prune, stash, notes, and HEAD operations.
The guiding rule remains the same:
Recover observable facts, but do not manufacture missing history.
Config-based hooks
Git's hook subsystem itself has also started to change.
For years, a hook essentially meant an executable file such as:
.git/hooks/pre-commit
.git/hooks/reference-transaction
Git 2.54 introduced config-based hooks.
More importantly for git-hooks-ext, the system allows wrappers to invoke event names that Git itself does not know about:
git hook run --allow-unknown-hook-name branch-created -- ...
That fits the architecture well:
Git
↓
reference-transaction
↓
git-hooks-ext
↓
branch-created
↓
Git's standard hook infrastructure
On Git 2.54 and newer, the bridge can therefore run as a config-based hook.
On Git 2.53 and older, the project still installs the classic:
.git/hooks/reference-transaction
hook.
What comes next
The obvious solution would be to add dozens of hooks directly to Git core:
branch-created
branch-deleted
branch-renamed
tag-created
tag-deleted
stash-updated
...
I am not convinced that this is the best boundary.
What matters more is that the lower layer provides a complete, correct, and consistent description of ref changes.
If:
git branch -m
changes two refs, the hook should see both.
If:
git branch -D
deletes an existing ref, its previous state should be observable.
If symbolic HEAD changes target, observers should see both the previous and new state.
The semantics should also remain consistent across the files and reftable backends.
If that contract is strong enough, the semantic layer can stay outside Git core:
Git: old, new, ref
git-hooks-ext: branch-created, tag-deleted, head-switched
The semantic layer can use both the transaction payload and state that is still observable during prepared, while still emitting its events only after the transaction commits.
Worktrees remain the exception because their lifecycle does not fit into the reference-transaction model.
There, either native lifecycle hooks will eventually be needed, or the wrapper will remain the correct observation point.
Conclusion
Long term, the project does not need Git to gain fifty new hooks.
It mostly needs one strong contract:
Every real reference change should be completely and correctly observable through
reference-transaction.
That does not necessarily mean that every useful fact has to appear directly in the committed payload.
Some information can be observed earlier during prepared, preserved for the duration of the transaction, and then used after the transaction commits.
But the boundary should remain clear.
If Git exposes a fact, either directly or through repository state that is still observable during the transaction, git-hooks-ext can translate it.
If Git never exposes the changed ref at all, as in some rename cases, the semantic layer should not invent it.
Git reports the facts.
git-hooks-ext translates them.
The user decides what should happen next.
That is the callback layer I was missing in Git.
Top comments (0)