Contents
  1. The Problem
  2. Layer 1: Local Git Hooks
  3. pre-commit
  4. pre-push
  5. Making hooks executable
  6. The limitation: hooks aren’t tracked by git
  7. Layer 2: GitHub Branch Protection Rules
  8. The Right Workflow
  9. Quick Reference
← All posts

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:

  1. Local git hooks: block commits and pushes on your machine before they happen
  2. 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
  1. Do all work on master (or a feature branch off master)
  2. Open a pull request targeting ganymed
  3. 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

LayerMechanismCan be bypassed with
Localpre-commit hookgit commit --no-verify
Localpre-push hookgit push --no-verify
RemoteGitHub branch protectionNothing (enforced server-side)
← All posts