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 "$@"
Checking the current branch against the remote refA guard that checks the current branch misses pushes like git push origin feature:main and wrongly blocks pushing a feature branch while main is checked out. Checking the remote ref from the hook's input catches exactly the pushes that would update a protected branch.Check current branchCheck remote ref (stdin)git push origin feature:mainallowed β€” wrongblockedon main, push featureblocked β€” wrongallowedgit push --allchecks one branchchecks every refthe input line says where the push is going; the current branch does not

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
What happens to a push touching a protected branchIf the push deletes the branch, the guard lets the server decide. If ALLOW_PROTECTED_PUSH names exactly this branch, the guard warns and allows it. Otherwise the guard refuses and suggests a pull request; the other pre-push checks still run in every case.A pushed ref matches the protected patterndeletionAllowserver decidesoverride names itWarn + allowother checks still runordinary updateRefuseopen a PRnaming the branch in the override makes a typo fail safe

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.

Layers protecting a release branchThe local pre-push guard catches accidents with a helpful message before anything leaves the machine. Server-side branch protection rejects whatever gets past it. Audit of who can bypass protection keeps the server rule meaningful.from friendly to authoritativeLocal pre-push guardaccident caught on the laptopServer branch protectionPR required, no force-pushBypass auditwho can override, reviewedthe local guard reduces surprises; only the server layer is a control

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.