Protecting Git Branches with Local Hooks and GitHub Branch Protection
How to prevent accidental commits and pushes to protected branches using git hooks locally, and a pointer to GitHub branch protection rules for remote enforcement.
The Problem
You have a branch (say production, release, or ganymed) that should only ever receive changes via pull requests merged from another branch. But nothing stops you from accidentally committing directly to it, or running git push origin ganymed out of habit.
Two layers protect you:
- Local git hooks: block commits and pushes on your machine before they happen
- GitHub branch protection rules: block pushes at the remote, regardless of how they were made
You need both. Local hooks can be bypassed with --no-verify. GitHub rules cannot.
Layer 1: Local Git Hooks
Git hooks are shell scripts that run automatically at specific points in the git workflow. They live in .git/hooks/ and must be executable. Git silently ignores hooks that aren’t marked executable, so chmod +x is required.
pre-commit
Runs before a commit is recorded. Exiting with a non-zero code aborts the commit.
# .git/hooks/pre-commit
#!/bin/sh
branch=$(git symbolic-ref --short HEAD 2>/dev/null)
if [ "$branch" = "ganymed" ]; then
echo "ERROR: Direct commits to 'ganymed' are not allowed."
echo " Work on 'master' and merge into 'ganymed' via a pull request."
exit 1
fi
git symbolic-ref --short HEAD returns the current branch name. The 2>/dev/null suppresses the error if you’re in a detached HEAD state.
pre-push
Runs before git push sends anything to the remote. Git passes the remote name as the first argument and pipes one line per ref being pushed to stdin, in the format:
<local_ref> <local_sha> <remote_ref> <remote_sha>
# .git/hooks/pre-push
#!/bin/sh
remote="$1"
while read local_ref local_sha remote_ref remote_sha; do
if echo "$remote_ref" | grep -q "refs/heads/ganymed"; then
echo "ERROR: Direct pushes to 'ganymed' are not allowed."
echo " Merge into 'ganymed' via a pull request from 'master'."
exit 1
fi
done
The while read loop handles the case where you push multiple refs at once (e.g. git push --all). Each ref is checked individually.
Making hooks executable
chmod +x .git/hooks/pre-commit
chmod +x .git/hooks/pre-push
The limitation: hooks aren’t tracked by git
.git/hooks/ is excluded from version control. If you clone the repo on another machine, or a teammate clones it, the hooks won’t be there.
The two common solutions:
Option A: committed hooks directory:
mkdir .githooks
# put hook scripts in .githooks/
git config core.hooksPath .githooks
Anyone cloning still needs to run the git config line manually. Put it in a make setup target or a bootstrap script so it’s one step.
Option B: a hook manager:
For Node projects, Husky installs hooks automatically via npm install. For other stacks, pre-commit (Python-based) works similarly.
Layer 2: GitHub Branch Protection Rules
GitHub branch protection rules are enforced server-side. Even if someone bypasses local hooks with --no-verify, or clones the repo without the hooks, GitHub will still reject the push.
Set these up under Settings → Branches → Add rule on your GitHub repository. The key options to enable for a protected branch are requiring pull requests before merging and blocking direct pushes.
The Right Workflow
With both layers in place, the only easy path is the intended one:
master → PR → ganymed
- Do all work on
master(or a feature branch offmaster) - Open a pull request targeting
ganymed - Merge the PR
Direct commits are caught by the pre-commit hook. Direct pushes are caught by the pre-push hook. Anything that bypasses both is caught by GitHub.
Quick Reference
| Layer | Mechanism | Can be bypassed with |
|---|---|---|
| Local | pre-commit hook | git commit --no-verify |
| Local | pre-push hook | git push --no-verify |
| Remote | GitHub branch protection | Nothing (enforced server-side) |