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-receive remains 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
pre-receive against updatepre-receive runs once per push with every ref update on standard input, and any failure rejects the whole push. update runs once per ref with that ref's details as arguments, and a failure rejects only that ref while the rest of the push proceeds.pre-receiveupdaterunsonce per pushonce per refinputstdin, all refsargs, one reffailure rejectswhole pushthat ref onlybest forcross-ref checksper-branch policyuse both: pre-receive for global rules, update for per-ref ones
# 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"
}
How the update hook decides one refFor each ref, the hook identifies the pusher, finds the first policy line whose glob matches the ref, checks the pusher against the allowed users or groups, checks whether the update is a force-push and whether that is allowed, and accepts or rejects that ref alone.Ref + oids$1 $2 $3PusherGL_USER / PUSH_USERPolicy linefirst glob matchUser + forceallowed?Accept / rejectthis ref onlyfirst match wins, so order lines from most to least specific

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: $?"
Outcomes for one ref under the policyIf the pusher is not in the allowed list for the matching policy line, the ref is rejected. If they are allowed but the update rewinds history on a branch where force-pushes are forbidden, it is rejected. Otherwise the ref is updated, independently of other refs in the same push.Update to refs/heads/main by bobbob not allowedReject refother refs continueallowed, rewinds historyReject refforce-push forbiddenallowed, fast-forwardAcceptref updatedthe message names the ref and the reason, so the pusher knows exactly what to fix

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.