Blocking direct pushes to protected branches locally Jump to heading
Server-side branch protection is the real control: if main requires pull requests, a direct push is rejected regardless of what the client does. But a rejected push is still a surprise, and on repositories where protection is incomplete β a self-hosted server, a fork, a personal project, a release branch someone forgot to protect β the push goes through. A local pre-push guard catches the common accident at the source: you meant to push your feature branch but pushed main, or you ran git push on the wrong checkout. It takes a few lines of shell, costs nothing in time, and gives a clear message instead of a server rejection or a broken branch. This page builds one that understands branch patterns and deliberate exceptions, within pre-push validation rules.
When to use this approach Jump to heading
- People occasionally push directly to main or release branches by mistake.
- Some repositories in your organisation lack server-side protection, or you push to several remotes.
- You want the push refused with a helpful message before it reaches the server.
- You understand that this is a convenience; enforcement belongs on the server, as in designing branch protection rulesets for an org.
Step 1 β Check the remote ref, not the local branch Jump to heading
The branch being updated on the remote is the third field of each pre-push input line. Check that, not the current branch: git push origin feature:main pushes to main from a feature branch.
#!/bin/sh
# scripts/hooks/protect-branches.sh β refuse pushes to protected remote refs
set -eu
protected='^refs/heads/(main|master|release/.+|production)$'
status=0
while read -r local_ref local_oid remote_ref remote_oid; do
if printf '%s\n' "$remote_ref" | grep -Eq "$protected"; then
echo "β Direct push to ${remote_ref#refs/heads/} refused. Open a pull request instead." >&2
status=1
fi
done
exit $status # .husky/pre-push (wrapper)
sh scripts/hooks/protect-branches.sh "$@" Step 2 β Allow deletions and tag pushes through Jump to heading
The guard should block updates to protected branches, not unrelated operations. Deleting a release branch after its end of life, or pushing tags, should not trip it. Deletions have an all-zero local object ID; tags have a refs/tags/ remote ref, which the pattern already excludes.
zero=$(git hash-object --stdin </dev/null | tr '0-9a-f' '0')
while read -r local_ref local_oid remote_ref remote_oid; do
[ "$local_oid" = "$zero" ] && continue # deletion: let the server decide
...
done Step 3 β Provide a deliberate, visible override Jump to heading
There are legitimate direct pushes: a maintainer fixing a broken main during an incident, a release manager pushing a version bump on a repository without bots. Give them a deliberate override that is obvious in the shell history, rather than teaching people --no-verify, which skips every other check too.
if [ "${ALLOW_PROTECTED_PUSH:-}" = "${remote_ref#refs/heads/}" ]; then
echo "β Pushing directly to ${remote_ref#refs/heads/} (ALLOW_PROTECTED_PUSH set)." >&2
continue
fi # Explicit, per-branch, per-command override
ALLOW_PROTECTED_PUSH=main git push origin main Step 4 β Make the protected list configurable per repository Jump to heading
Different repositories protect different branches. Read the pattern from Git configuration, with a sensible default, so each repository can adjust it without editing the shared script.
protected=$(git config --get hooks.protectedBranches || echo '^refs/heads/(main|master|release/.+)$') # Per repository
git config hooks.protectedBranches '^refs/heads/(main|develop|release/.+|hotfix/.+)$' If teams share configuration through an included file, this setting can live there, as in shipping a team gitconfig with includeIf.
Step 5 β Test the guard with synthetic pushes Jump to heading
The guard is pure input processing, so every case can be tested without a remote.
h=$(git rev-parse HEAD); z=0000000000000000000000000000000000000000
printf 'refs/heads/feature %s refs/heads/main %s\n' "$h" "$h" | sh scripts/hooks/protect-branches.sh; echo "to main: $?" # 1
printf 'refs/heads/feature %s refs/heads/feature %s\n' "$h" "$h" | sh scripts/hooks/protect-branches.sh; echo "to feature: $?" # 0
printf 'refs/heads/old %s refs/heads/release/1.x %s\n' "$z" "$h" | sh scripts/hooks/protect-branches.sh; echo "delete: $?" # 0
printf 'refs/heads/main %s refs/heads/main %s\n' "$h" "$h" | ALLOW_PROTECTED_PUSH=main sh scripts/hooks/protect-branches.sh; echo "override: $?" # 0 Step 6 β Keep the server as the real control Jump to heading
A local hook can be skipped with --no-verify, removed, or never installed on a new machine. Treat it as a friendly early warning. The rule itself must exist server-side: required pull requests, no force-pushes, no deletions, applied to the same branch patterns.
The server side is covered in blocking force-pushes with a pre-receive hook and auditing who can push to protected branches.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why not just rely on server-side protection? Jump to heading
Do rely on it. The local guard improves the experience β a clear message before anything is sent β and covers remotes where you cannot configure protection, such as a colleagueβs fork or a plain SSH server.
Will this block pushes made by release automation? Jump to heading
Automation running in CI should not have developer hooks installed at all; set HUSKY=0 or equivalent, as described in skipping Husky in CI and production installs.
Does git push --no-verify bypass this? Jump to heading
Yes, along with every other pre-push check. That is why the server rule is mandatory and why the override variable exists: it lets people make a deliberate exception without disabling everything else.
Related Jump to heading
- Pre-Push Validation Rules β the parent topic.
- Finding the New Commits in a pre-push Hook β the input format this guard reads.
- Freezing Deploys with Branch Protection β protecting branches temporarily during freezes.
- Recovering from an Accidental Force-Push β when an unguarded push did damage.