About · Quick Start · Install · Worktrees · Configuration · Hook Arguments · Compatibility · Development · Website ↗
Git's reference-transaction hook reports raw old and new values together
with ref names. It does not tell a hook that a branch was created, a tag was
deleted or a ref was renamed.
git-hooks-ext turns those low-level updates into semantic events such as
branch-created, tag-deleted, remote-head-updated and ref-created. It
also adds the worktree lifecycle events that Git does not provide.
Supported ref events are grouped by purpose:
| Ref | Created | Deleted | Updated | Renamed |
|---|---|---|---|---|
| Branch | branch-created | branch-deleted | branch-updated | branch-renamed |
| Remote branch | remote-branch-created | remote-branch-deleted | remote-branch-updated | remote-branch-renamed |
| Tag | tag-created | tag-deleted | tag-updated | tag-renamed |
| Note | note-created | note-deleted | note-updated | note-renamed |
| Stash | stash-created | stash-deleted | stash-updated | — |
| Ref | Created | Deleted | Updated |
|---|---|---|---|
| Replace | replace-created | replace-deleted | replace-updated |
| Prefetch | prefetch-created | prefetch-deleted | prefetch-updated |
| Bisect ref | bisect-ref-created | bisect-ref-deleted | bisect-ref-updated |
| Rewritten ref | rewritten-ref-created | rewritten-ref-deleted | rewritten-ref-updated |
| Per-worktree ref | worktree-ref-created | worktree-ref-deleted | worktree-ref-updated |
| Ref | Created | Deleted | Updated |
|---|---|---|---|
Other refs/* | ref-created | ref-deleted | ref-updated |
| Root ref | root-ref-created | root-ref-deleted | root-ref-updated |
| Ref | Created | Deleted | Updated |
|---|---|---|---|
| Remote HEAD | remote-head-created | remote-head-deleted | remote-head-updated |
| HEAD | Updated | Attached | Detached | Switched |
|---|---|---|---|---|
| HEAD | head-updated | head-attached | head-detached | head-switched |
*-created is a creation candidate, not an independent proof that the ref did
not exist. Git uses an all-zero old value both when it creates a ref and when
an update does not require a particular previous value. This bridge maps every
zero -> value record to *-created, which is usually a creation. It runs
only at the committed state, however, so the ref already has its new value
and cannot be checked with git rev-parse to distinguish those cases. Hooks
that need that distinction must treat *-created as a candidate.
The worktree-ref-* events describe updates under refs/worktree/*. They are
separate from the lifecycle events below.
Git has no hook for observing
worktree lifecycle operations, so worktree-* events are available only when
the command is run through the ghe worktree wrapper.
| Wrapper command | Event |
|---|---|
ghe worktree add | worktree-created |
ghe worktree remove | worktree-removed |
ghe worktree move | worktree-moved |
ghe worktree lock | worktree-locked |
ghe worktree unlock | worktree-unlocked |
ghe worktree prune | worktree-pruned |
ghe worktree repair | worktree-repaired |
The wrapper forwards all arguments to git worktree, compares the worktree
state before and after a successful mutating command, and emits the observed
lifecycle events. Calling git worktree directly bypasses it and cannot emit
these events.
Event names are identical in Git config, classic hook filenames and dry-run output. The command compatibility matrix shows which commands produced each event in end-to-end tests.
Enable the extension in a Git repository and add a configured hook:
git-hooks-ext install
# or: ghe install
mkdir -p scripts
cat > scripts/announce-branch <<'SH'
#!/bin/sh
echo "created branch: $1"
SH
chmod +x scripts/announce-branch
ghe add branch-created announce-branch ./scripts/announce-branchghe is a shorter, equivalent command installed alongside git-hooks-ext.
Run ghe doctor inside a repository to show the detected Git and
reference backend, installed bridge, and compatibility status for every event.
Now create a branch:
git branch topicThe hook prints:
created branch: topic
Run ghe events to list every supported event. If you use
core.hooksPath, put the event hook in that directory instead.
brew tap ciembor/git-hooks-ext
# Homebrew 7+: trust this formula (omit on older versions).
brew trust --formula ciembor/git-hooks-ext/git-hooks-ext
brew install ciembor/git-hooks-ext/git-hooks-extThe public tap builds from the checksummed source archive in the GitHub release; it does not depend on local files or paths.
Download the package and checksums from the v0.4.0 release:
arch=$(dpkg --print-architecture)
curl -fLO "https://github.com/ciembor/git-hooks-ext/releases/download/v0.4.0/git-hooks-ext_0.4.0-1_${arch}.deb"
curl -fLO https://github.com/ciembor/git-hooks-ext/releases/download/v0.4.0/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
sudo apt install "./git-hooks-ext_0.4.0-1_${arch}.deb"Packages are available for AMD64 (Intel/AMD 64-bit) and ARM64 (AArch64).
Other architectures are not currently published.
This is a downloadable package installed with APT, not an APT repository:
automatic upgrades via apt upgrade are not yet available.
The release also contains the corresponding GPL-2.0-only source archive.
A multi-platform image is published to GitHub Container Registry:
docker run --rm ghcr.io/ciembor/git-hooks-ext:latest --versionNative packages are recommended when installing hooks in a repository. The container image is useful for inspecting the CLI and processing input from a mounted or piped-in repository environment.
After installing the package, enable it in each repository:
git-hooks-ext install
# or: ghe installThe command detects the installed Git version. With Git 2.54 or later it uses
config-based hooks; with Git 2.53 or older it installs a legacy
reference-transaction hook and prints migration instructions. After upgrading
Git, remove that legacy bridge and run git-hooks-ext install again (or ghe install).
Remove the bridge with:
ghe uninstallThis removes the config-based bridge and a legacy bridge installed by a current
version of git-hooks-ext, while leaving any other reference-transaction
hook untouched.
Fedora, Arch Linux and Alpine packages are also available. See Distribution and Packaging for package details, build recipes and installation tests.
Git has no native hooks for removing, moving, locking, pruning or repairing a
worktree. Run worktree commands through git-hooks-ext to add those events:
ghe worktree add -b feature ../feature
ghe worktree lock --reason "offline disk" ../feature
ghe worktree move ../feature ../feature-renamed
ghe worktree remove ../feature-renamedAll arguments are forwarded to git worktree. The command snapshots
git worktree list --porcelain -z before and after a successful mutation and
emits events only for observed lifecycle changes. Read-only commands are also
forwarded, so ghe worktree list behaves like git worktree list.
Commands run directly as git worktree ... bypass this frontend and do not
emit lifecycle events.
For example, a classic hook can react to a newly created worktree:
cat >.git/hooks/worktree-created <<'SH'
#!/bin/sh
printf 'worktree %s created at %s\n' "$3" "$1"
SH
chmod +x .git/hooks/worktree-created
ghe worktree add -b feature ../featureWith Git 2.54+ config-based hooks, you can also configure hooks through Git config:
git-hooks-ext install
# or: ghe install
ghe add branch-created create-branch-env ./scripts/create-branch-env
ghe list
ghe show announce-branch
ghe remove announce-branchlist prints the configured hook name, event and command. show and remove
operate on one named event hook. These commands accept the same optional scope
as add; they use the local repository configuration by default.
Reference create, update and delete hooks receive positional arguments:
<short-name> <full-ref> <old-value> <new-value>
Rename hooks receive:
<old-short-name> <new-short-name> <old-ref> <new-ref> <object-value>
Rename events are detected for branches, remote branches, tags and notes.
The head-attached, head-detached and head-switched hooks use the same
four arguments as head-updated. Symbolic values use Git's
ref:refs/heads/<name> representation.
Worktree creation, removal, pruning and repair hooks receive:
<path> <head-value> <branch-ref>
The branch ref is empty for a detached worktree. Move hooks receive:
<old-path> <new-path> <head-value> <branch-ref>
Lock and unlock hooks receive the path and lock reason. The reason is empty when none was supplied:
<path> <reason>
By default, events are emitted only for the committed transaction state. This
keeps user hooks post-factum and avoids aborting Git ref transactions.
Rename detection is best-effort. Git's reference-transaction hook reports ref
updates, not user intent, so a delete and create of refs pointing at the same
object can look like a rename. A rename is emitted only when both the deletion
and creation have a unique match within the same ref namespace.
Events depend on Git providing a usable reference-transaction payload. The
hook is available from Git 2.28, but the tested versions do not report both
sides of git branch -m. From Git 2.31 through 2.55, ordinary git branch -D
and git tag -d report zero -> zero, so they cannot produce semantic delete
events through this bridge. Creation and explicit git update-ref -d remain
usable. See the Git compatibility matrix for tested
versions, ref backends and the reproducible probe.
References not covered by a named namespace still produce ref-created,
ref-updated or ref-deleted when their name is below refs/. Ref names
outside refs/* that pass through the ref backend, including custom names
such as CUSTOM and misc/path, produce root-ref-*. Direct FETCH_HEAD
and MERGE_HEAD are pseudorefs and are not classified as root refs. Git's
main-worktree/ and worktrees/<name>/ aliases for per-worktree refs are
classified by their underlying ref name; hook arguments retain the full
alias-qualified name. Git can pass alias-qualified FETCH_HEAD and
MERGE_HEAD through the ref backend, and these emit root-ref-*.
Every changed HEAD value produces head-updated. A known direct value
changing to a symbolic value also produces head-attached; a symbolic value
changing to a known direct value produces head-detached; and a change between
different symbolic targets produces head-switched. Git can report an all-zero
old value for ordinary git symbolic-ref, checkout and even explicit
git update-ref --no-deref HEAD operations when HEAD already exists. In
that case the extension emits only head-updated:
it cannot safely infer the previous attachment state from a zero value.
remote-head-* uses the first path component after refs/remotes/ as the
remote name. Remote names containing / are ambiguous with branch names
ending in /HEAD when only the ref name is available; these are treated as
remote branches by this classifier.
Other ref commands can provide incomplete information too. In the tested Git
versions, git notes append, git notes remove and a second git stash push
report a zero old value even though those refs already exist; the bridge
therefore emits another note-created or stash-created creation candidate
instead of an update. git remote prune removes its tracking branch without a
semantic deletion. The command matrix below separates these limitations from
events emitted when Git supplies complete transactions.
Measured on 2026-09-19. The detailed CI matrix tests Git 2.27–2.55, Apple Git
2.39.3 and the files and reftable backends. Git versions < 2.28 do not
provide the required reference-transaction hook.
This table lists each tested command, its expected event and the Git versions
that emit it. The direct update-ref rows show which events remain reachable
when a higher-level command omits usable transaction data. Rows for
ghe worktree uses the extension's frontend; plain git worktree
does not run these hooks.
| Hook | Command | Supported Git versions |
|---|---|---|
branch-created | git branch topic | Git ≥ 2.28 |
branch-updated | git commit | Git ≥ 2.28 |
branch-deleted | git branch -D topic | 2.28 ≤ Git ≤ 2.30 ² |
branch-deleted | git update-ref -d refs/heads/topic | Git ≥ 2.28 |
branch-renamed | git branch -m old new | ❌ ¹ |
branch-renamed | git update-ref --stdin (heads) | Git ≥ 2.28 |
remote-branch-created | git fetch origin | Git ≥ 2.28 |
remote-branch-updated | git fetch origin | Git ≥ 2.28 |
remote-branch-deleted | git remote prune origin | ❌ ² |
remote-branch-deleted | git update-ref -d refs/remotes/origin/topic | Git ≥ 2.28 |
remote-branch-renamed | git remote rename origin upstream | Git ≥ 2.55 |
remote-branch-renamed | git update-ref --stdin (remotes) | Git ≥ 2.28 |
tag-created | git tag v1 | Git ≥ 2.28 |
tag-updated | git tag -f v1 | Git ≥ 2.28 |
tag-deleted | git tag -d v1 | 2.28 ≤ Git ≤ 2.30 ² |
tag-deleted | git update-ref -d refs/tags/topic | Git ≥ 2.28 |
tag-renamed | git update-ref --stdin (tags) | Git ≥ 2.28 |
stash-created | first git stash push | Git ≥ 2.28 |
stash-updated | second git stash push | ❌ |
stash-updated | git update-ref refs/stash | Git ≥ 2.28 |
stash-deleted | git stash clear | Git ≥ 2.28 |
note-created | git notes add | Git ≥ 2.28 |
note-updated | git notes append | ❌ |
note-updated | git notes remove | ❌ |
note-updated | git update-ref refs/notes/topic | Git ≥ 2.28 |
note-deleted | git update-ref -d refs/notes/topic | Git ≥ 2.28 |
note-renamed | git update-ref --stdin (notes) | Git ≥ 2.28 |
remote-head-created | git remote set-head origin main | Git ≥ 2.54 |
remote-head-created | git remote set-head origin topic after fetch | Git ≥ 2.54 |
remote-head-updated | git update-ref --stdin (symref-update) | Git ≥ 2.54 |
remote-head-deleted | git remote set-head -d origin | ❌ |
remote-head-deleted | git update-ref --stdin (symref-delete) | Git ≥ 2.54 |
replace-created | git replace <old> <new> | Git ≥ 2.28 |
replace-updated | git replace -f <old> <new> | Git ≥ 2.28 |
replace-deleted | git replace -d <old> | Git ≥ 2.28 |
prefetch-created | git fetch --prefetch origin | Git ≥ 2.32 |
prefetch-updated | second git fetch --prefetch origin | Git ≥ 2.32 |
bisect-ref-created | git bisect start <bad> <good> | Git ≥ 2.28 |
remote-head-* | direct git update-ref | Git ≥ 2.28 |
replace-* | direct git update-ref | Git ≥ 2.28 |
prefetch-* | direct git update-ref | Git ≥ 2.28 |
bisect-ref-* | direct git update-ref | Git ≥ 2.28 |
rewritten-ref-* | direct git update-ref | Git ≥ 2.28 |
worktree-ref-* | direct git update-ref | Git ≥ 2.28 |
ref-* | direct git update-ref | Git ≥ 2.28 |
head-updated | symbolic/ref-backend transaction | Git ≥ 2.54 |
head-attached | symbolic/ref-backend transaction | Git ≥ 2.54 |
head-switched | symbolic/ref-backend transaction | Git ≥ 2.54 |
remote-head-created | symbolic/ref-backend transaction | Git ≥ 2.54 |
root-ref-* | symbolic/ref-backend transaction | Git ≥ 2.54 |
head-detached | git checkout --detach | ❌ |
worktree-created | ghe worktree add | Git ≥ 2.39.3 |
worktree-removed | ghe worktree remove | Git ≥ 2.39.3 |
worktree-moved | ghe worktree move | Git ≥ 2.39.3 |
worktree-locked | ghe worktree lock | Git ≥ 2.39.3 |
worktree-unlocked | ghe worktree unlock | Git ≥ 2.39.3 |
worktree-pruned | ghe worktree prune | Git ≥ 2.39.3 |
worktree-repaired | ghe worktree repair | Git ≥ 2.39.3 |
Some compatibility gaps are caused by Git bugs, rather than limitations in
git-hooks-ext. I filed the following reports upstream with proposed fixes
for the transaction payloads that prevent the corresponding semantic events:
- ¹
git branch -momits the destination ref from thereference-transactionhook. - ²
git branch -Dandgit tag -dreport zero OIDs from Git 2.31 onward.
The compatibility notes explain the test method and Git's raw transaction behavior.
Build instructions, source layout, test documentation and packaging notes are in DEVELOPMENT.md.