Finer-Grained Control
While pre-receive sees all refs in a single invocation, update fires once per ref. This makes it the right tool when your policy differs by branch.
Unlike pre-receive which reads from stdin, update receives its arguments directly as command-line parameters: the ref name, the old SHA, and the new SHA, in that order — which makes per-branch logic straightforward, since the hook can check the ref name first and immediately exit clean for any branch it doesn't care about.
A practical use case: prevent force-pushes to protected branches while allowing them on feature branches. The core check is git merge-base --is-ancestor <old> <new>, which is the canonical way to detect a force-push: in a normal push, the old commit is always an ancestor of the new commit. In a force-push (after a rebase or git push --force), the old commit is no longer in the new commit's ancestry chain, so the ancestor check fails — and that failure is exactly the signal the hook rejects on, for branches matching main or a release/* pattern, while leaving feature branches untouched.