Every push you make lands on the server side through git-receive-pack, and that is where a rejected policy check quietly saves you. This walkthrough builds a disposable bare server, accepts a normal push, rejects a policy violation before any ref changes, and keeps non-fast-forward updates under control. The examples use git-receive-pack from Git 2.43.0, supplied by Ubuntu's git-man package. Allow about fifteen minutes and a writable directory under /tmp.
git-receive-pack is normally started by git push on the receiving side, you rarely invoke it by hand. The useful work here is preparing the bare repository, setting its receive policy, and installing hooks in its hooks directory.
Use a temporary path so this test cannot touch a real hosted repository. These are ordinary user commands, no sudo needed:
$ rm -rf /tmp/receive-pack-demo
$ git init --bare /tmp/receive-pack-demo.git
$ git init /tmp/receive-pack-client
$ git -C /tmp/receive-pack-client config user.name 'Demo User'
$ git -C /tmp/receive-pack-client config user.email '[email protected]'
$ printf '%s\n' 'first commit' > /tmp/receive-pack-client/README
$ git -C /tmp/receive-pack-client add README
$ git -C /tmp/receive-pack-client commit -m 'Initial commit'
$ git -C /tmp/receive-pack-client branch -M main
$ git -C /tmp/receive-pack-client remote add origin /tmp/receive-pack-demo.git
$ git -C /tmp/receive-pack-client push -u origin HEAD
The final command should create the remote branch and report a new branch named after the client's current one. Check the receiving repository without changing it:
$ git --git-dir=/tmp/receive-pack-demo.git show-ref
<commit-id> refs/heads/<branch-name>
Checkpoint: if show-ref prints nothing, the push did not create a ref. Check the preceding push output before going further.
A receive repository can refuse a push that would move a ref backwards or sideways. Set the documented receive.denyNonFastForwards option in the bare repository:
$ git --git-dir=/tmp/receive-pack-demo.git config receive.denyNonFastForwards true
$ git --git-dir=/tmp/receive-pack-demo.git config --get receive.denyNonFastForwards
true
Security boundary: this is a server-side setting, not a suggestion. It does not block ordinary fast-forward pushes. A force push can still be an intentional operational action elsewhere, but do not weaken this setting just to turn a failed deployment green.
To test the boundary safely, make a second commit on the client, push it, then create a separate history and try to replace the branch. The second push should be rejected:
$ printf '%s\n' 'second commit' >> /tmp/receive-pack-client/README
$ git -C /tmp/receive-pack-client add README
$ git -C /tmp/receive-pack-client commit -m 'Second commit'
$ git -C /tmp/receive-pack-client push
$ git -C /tmp/receive-pack-client checkout --orphan replacement
$ git -C /tmp/receive-pack-client rm -rf .
$ printf '%s\n' 'unrelated history' > /tmp/receive-pack-client/README
$ git -C /tmp/receive-pack-client add README
$ git -C /tmp/receive-pack-client commit -m 'Unrelated history'
$ git -C /tmp/receive-pack-client push --force origin HEAD:main
The last command should report the update was rejected as a non-fast-forward. This example deliberately names the branch main, keep the ref name consistent in the push and hook if you adapt it. To restore this disposable test, remove both paths and repeat step 1, do not force-push to a production repository as an undo strategy.
The pre-receive hook runs once before any proposed ref is updated. It receives one line per proposed ref on standard input: old object ID, new object ID, ref name. A non-zero exit status rejects the whole update, and the update, post-receive and post-update hooks that would normally follow do not run.
Install this deliberately small policy in the disposable repository. It rejects updates to the protected refs/heads/main ref, adjust the name if your test branch differs:
$ cat > /tmp/receive-pack-demo.git/hooks/pre-receive <<'EOF'
#!/bin/sh
while read old new ref
do
if test "$ref" = refs/heads/main
then
echo 'main is protected by the pre-receive hook' >&2
exit 1
fi
done
exit 0
EOF
$ chmod +x /tmp/receive-pack-demo.git/hooks/pre-receive
$ test -x /tmp/receive-pack-demo.git/hooks/pre-receive && echo 'hook is executable'
hook is executable
Push a change to the protected ref and expect the hook's message followed by a rejected push. This hook changes server behaviour, review it like production code and keep a copy before editing. Recovery is simple: rename or remove the hook only once you have decided the policy should no longer apply.
update: a per-ref decision. Git invokes it once per ref with the ref name and old and new object IDs. A successful return is only a prerequisite, it does not guarantee the ref actually gets updated.post-receive: for notifications or other work needing the list of successfully updated refs. Runs once, after ref updates, reading the successful updates on standard input. Its exit code is ignored, though Git can report a non-zero result.post-update: for repository-wide cleanup after updates. Receives the updated ref names as arguments and also cannot undo a completed update.Warning: do not send mail or trigger an external deployment from update. A later check can still reject the ref, leaving that notification false. Put side effects in post-receive instead, and make them retryable if delivery matters.
Incoming objects first land in a temporary quarantine directory under $GIT_DIR/objects, and move into the main object store only after pre-receive succeeds. If the pack, connectivity checks, or the hook fails the push, the quarantined data is removed.
That stops repeated failed pushes from accumulating unreferenced data, but it creates a strict hook rule: a pre-receive hook must not update a ref to an object still in quarantine. Other processes cannot reliably see that object yet, so Git rejects ref updates from inside the hook for exactly this reason. Inspect incoming objects from the hook if you need to, but leave ref changes to receive-pack's normal update phase.
First identify which side rejected the operation and keep the exact client output. Then check the receiving repository's policy and hook permissions:
$ git --git-dir=/tmp/receive-pack-demo.git config --get-regexp '^receive\.'
receive.denynonfastforwards true
$ ls -l /tmp/receive-pack-demo.git/hooks/pre-receive
$ git --git-dir=/tmp/receive-pack-demo.git show-ref
Do not run sudo git-receive-pack as a first diagnostic, it can make ownership and hook-environment problems harder to see. The service account that owns the repository should normally run the receive process; reach for elevated privileges only to inspect or repair permissions under an approved server-maintenance procedure.
receive.denyNonFastForwards.