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
looks 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 the old value, new value, and ref name. 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:
| old | new | semantics |
|---|---|---|
| zero | value | usually created, but can also be an unconstrained update |
| value | zero | deleted |
| value A | value B | updated |
A zero old value is not, by itself, proof that the ref did not exist before the transaction. Git can also use zero when an update does not require a specific previous value. Because git-hooks-ext reacts only after the transaction reaches committed, it cannot reliably distinguish those cases from the payload alone and currently classifies zero -> value as creation.
Combining both pieces of information:
zero -> OID
refs/heads/topic
is classified by git-hooks-ext as branch-created, while:
OID A -> OID B
refs/remotes/origin/main
becomes remote-branch-updated.
Because git-hooks-ext interprets the resulting reference transaction rather than the command, creating a branch through:
git branch topic
or:
git update-ref refs/heads/topic <oid>
produces the same semantic result.
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 A and creating B at the same commit may look exactly like renaming A to B at the ref-transaction level.
reference-transaction describes state changes, not user intent, so rename detection is deliberately best-effort.
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 as 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 same rule applies here as everywhere else: if Git does not provide enough information, git-hooks-ext does not guess. If the old value arrives as zero, we cannot safely determine whether HEAD used to be symbolic or direct.
Why only committed
git-hooks-ext reacts only to the committed state by default. Its semantic hooks are therefore post-factum.
branch-created means that the committed transaction was classified as a branch creation from the data Git provided, not that Git is about to create it.
The events describe repository state. They are not another layer for rejecting Git transactions.
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, or 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, or 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.
Git: bugs, RFCs, and what comes next
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.
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 the tested versions, the files backend reports the deletion of the old branch but not the corresponding creation of the destination ref. With only one side of the rename, there is no way to determine the new name.
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
For ordinary branch and tag deletion, Git 2.28 through 2.30 reported enough information to recognize the deletion. From Git 2.31 through the tested 2.55 versions, the hook may instead receive the equivalent of:
zero -> zero
which no longer tells us the previous OID. Interestingly:
git update-ref -d refs/heads/topic
still provides enough information for a correct branch-deleted, so the limitation belongs to a particular Git code path rather than to the reference-transaction model itself.
I reported that issue as well and proposed a patch:
git branch -D and git tag -d report zero OIDs from Git 2.31 onward
Similar problems appear with some remote prune, stash, notes, and HEAD operations.
git-hooks-ext deliberately does not patch such cases by inspecting reflogs or process arguments. If Git reports zero -> zero, or a rename contains only one side, the extension does not invent the missing history.
A semantic layer should translate available facts, not manufacture a plausible 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 new 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 produces a reference-transaction, git-hooks-ext translates it into branch-created, and Git's standard hook infrastructure can execute callbacks configured for that event.
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 value should be available. 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
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.
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)