Writing an update hook for per-branch policy Jump to heading
Self-hosted Git servers offer three receive-side hooks, and most guides only use pre-receive. It runs once per push and sees every ref update together, so a single failing ref rejects the entire push. The update hook is different: Git runs it once for each ref being updated, with that ref’s name and old and new values as arguments, and a non-zero exit rejects only that ref while the others go through. That makes it the natural place for per-branch policy — who may push to release/*, whether main may be force-pushed, which tag names are allowed — where one bad ref should not block an otherwise fine push. This page writes one, within server-side hook enforcement.
When to use this approach Jump to heading
- You run a self-hosted Git server — a bare repository over SSH, Gitolite, or a forge that supports custom server hooks.
- Different branches need different rules, and rules depend on who is pushing.
- A push of several refs should succeed for the allowed ones even if one is refused.
- For all-or-nothing checks across a push, such as signatures,
pre-receiveremains the better fit, as in verifying commit signatures in a pre-receive hook.
Step 1 — Know what the update hook receives Jump to heading
Git calls hooks/update with three arguments: the ref name, the old object ID and the new object ID. It runs once per ref, after pre-receive has passed and before the ref is updated.
#!/bin/sh
# hooks/update <refname> <old-oid> <new-oid>
refname=$1 old=$2 new=$3
echo "update: $refname $old -> $new" >&2
exit 0 # Verification: push two branches and see two update invocations in the output
git push scratch feature-a feature-b 2>&1 | grep 'remote: update:' Step 2 — Identify who is pushing Jump to heading
Per-branch policy usually depends on the pusher. How you learn their identity depends on the server. Gitolite sets GL_USER; a plain SSH setup can use the key’s command= option in authorized_keys to set an environment variable; many forges expose a variable to custom hooks.
# ~git/.ssh/authorized_keys — one line per user, forcing git-shell and recording the user
command="env PUSH_USER=alice git-shell -c \"$SSH_ORIGINAL_COMMAND\"",no-port-forwarding,no-pty ssh-ed25519 AAAA... alice user=${GL_USER:-${PUSH_USER:-unknown}}
[ "$user" = unknown ] && { echo "cannot identify pusher; refusing $refname" >&2; exit 1; } Refusing when the identity is unknown is important: a policy that defaults to “allow” when it cannot tell who is pushing is not a policy.
Step 3 — Express the policy as data Jump to heading
Hard-coding rules in shell case statements works until there are more than a handful. Keep the policy in a small file next to the hook, owned by the server administrators, and have the hook read it.
# /etc/git/branch-policy —
refs/heads/main @release-managers no
refs/heads/release/* @release-managers no
refs/heads/hotfix/* @oncall,@release-managers no
refs/tags/v* @release-managers no
refs/heads/* * yes policy_for() { # prints "<users> <force>" for the first matching glob
while read -r glob users force; do
case "$glob" in \#*|'') continue ;; esac
case "$1" in $glob) echo "$users $force"; return ;; esac
done < /etc/git/branch-policy
echo "- no"
} Step 4 — Enforce membership and force-push rules Jump to heading
With the pusher and the matching policy line, the rest is two checks: is the user allowed, and if this is a non-fast-forward update, is that allowed?
#!/bin/sh
# hooks/update — per-branch policy
set -eu
refname=$1 old=$2 new=$3
zero=$(git hash-object --stdin </dev/null | tr '0-9a-f' '0')
user=${GL_USER:-${PUSH_USER:-unknown}}
set -- $(policy_for "$refname"); users=$1 force=$2
in_group() { getent group "${1#@}" | cut -d: -f4 | tr ',' '\n' | grep -qx "$user"; }
allowed=no
for u in $(echo "$users" | tr ',' ' '); do
case "$u" in '*') allowed=yes ;; @*) in_group "$u" && allowed=yes ;; *) [ "$u" = "$user" ] && allowed=yes ;; esac
done
[ "$allowed" = yes ] || { echo "✗ $user may not push to $refname" >&2; exit 1; }
if [ "$old" != "$zero" ] && [ "$new" != "$zero" ] && ! git merge-base --is-ancestor "$old" "$new"; then
[ "$force" = yes ] || { echo "✗ force-push to $refname is not allowed" >&2; exit 1; }
fi
exit 0 Paste the policy_for function above the main logic. Group membership here uses system groups through getent; substitute your server’s group mechanism.
Step 5 — Test the hook without pushing Jump to heading
The hook is a script with three arguments and a couple of environment variables, so it can be tested directly against a scratch bare repository.
cd /srv/git/scratch.git
old=$(git rev-parse main~1); new=$(git rev-parse main)
PUSH_USER=alice sh hooks/update refs/heads/main "$old" "$new"; echo "alice → main: $?"
PUSH_USER=bob sh hooks/update refs/heads/feature/x "$old" "$new"; echo "bob → feature: $?"
PUSH_USER=bob sh hooks/update refs/heads/main "$new" "$old"; echo "bob rewind main: $?" More thorough approaches to server hook testing are in testing server-side hooks safely.
Step 6 — Install it and keep it out of reach of pushers Jump to heading
Install the hook in the bare repository’s hooks/ directory, owned by the server account, executable, and not writable by any user who can push. Version the hook and the policy file in an administration repository and deploy them from there.
install -m 0755 -o git -g git update /srv/git/app.git/hooks/update
install -m 0644 -o root -g root branch-policy /etc/git/branch-policy Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Can the update hook see the other refs in the push? Jump to heading
No — it sees only its own ref. If a rule depends on the combination, such as “a release branch push must come with a tag”, implement it in pre-receive.
Does a rejected ref leave the push half-applied? Jump to heading
Yes, by design: allowed refs update and rejected ones do not. Clients that need all-or-nothing behaviour can push with --atomic, which makes the server reject the whole push if any ref fails.
Do hosted forges run update hooks? Jump to heading
Hosted forges do not run arbitrary custom hooks; they provide branch protection and rulesets instead. Self-managed GitLab supports server hooks including update, as covered in GitLab push rules and server hooks.
Related Jump to heading
- Server-Side Hook Enforcement — the parent topic.
- Blocking Force-Pushes with a pre-receive Hook — the all-or-nothing version of one rule here.
- Enforcing Commit Message Policy in pre-receive — content checks that belong in pre-receive.
- Designing Branch Protection Rulesets for an Org — the hosted-forge equivalent.