Git: Prevent pushing directly to master

Git hooks make it easy to extend git's behaviour. One very common use case is protecting the master branch. This post is going to quickly go over one way to do this.

Setting up repos

For this example I'm going to create a local bare repository. This is done using git init:

$ git init --bare example.git
Initialized empty Git repository in /home/example/example.git/

The next step is to clone a working copy of the bare repository:

$ git clone example.git working_copy
Cloning into 'working_copy'...
warning: You appear to have cloned an empty repository.
done.

Note: for this example the bare repository is going to be on the same host, however it could easily be hosted remotely and accessed via ssh.

Update hooks

When references are pushed to a repository any configured update hook will be invoked just before each reference is updated. The update hook is given three arguments:

  1. The name of the ref being updated

  2. The old object name stored in the ref (old SHA1 commit hash)

  3. The new object name to be stored in the ref (new SHA1 commit hash)

The ref will only be updated if the return code of the hook is 0. Any non-zero return code will prevent the update taking place.

Configuring an update hook is just a case of adding a file call update to the hooks directory. The file can be a script or a binary, and must have execute permissions.

Rejecting any master reference

The scrip below will prevent any updates being pushed to the master branch:

#!/bin/sh
#
# Reject any update on the master branch
#

refname="$1"

# Validate arguments
if ! [ "$refname" ]; then
  echo "Usage: $0 <ref>" >&2
  exit 1
fi

# Reject updates if the ref is master
if [ "$refname" = 'refs/heads/master' ]; then
  echo "Don't commit to master" >&2
  exit 1
fi

Enabling the hook is just a case of moving it into the correct directory in the example repo and adding execute permissions:

mv update_script.sh example.git/hooks/update
chmod 755 example.git/hooks/update

Once installed any new commits being pushed to master will be rejected:

$ git push origin master
Counting objects: 3, done.
Writing objects: 100% (3/3), 236 bytes | 0 bytes/s, done.
Total 3 (delta 0), reused 0 (delta 0)
remote: Don't commit to master
remote: error: hook declined to update refs/heads/master
To /home/example/example.git
 ! [remote rejected] master -> master (hook declined)
error: failed to push some refs to '/home/example/example.git'

Being more selective

There are a couple of problems with the hook above:

  1. If the master branch doesn't exist, for example in a new repository, you can't create it by pushing.
  2. You can't fast-forward master to an existing branch using git push.

The script below addresses these two problems:

#!/bin/sh
#
# Reject updates to master if the new reference doesn't already exist.
#

refname="$1"
oldrev="$2"
newrev="$3"

# Validate arguments
if ! [ "$refname" ] && [ "$oldrev" ] && [ "$newrev" ]; then
  echo "Usage: $0 <ref> <oldrev> <newrev>" >&2
  exit 1
fi

# Reject updates made directly to master
if [ "$refname" = 'refs/heads/master' ]; then

  # Allow update if master doesn't exist
  zero="0000000000000000000000000000000000000000"
  if [ "$oldrev" = "$zero" ]; then
    exit 0
  fi

  # Reject updates if the new revision doesn't exist in an existing branch
  if ! git rev-list --branches | grep -q "$newrev"; then
    echo "Don't commit directly to master!"
    exit 1
  fi
fi

The first issue is fixed by checking if the old reference is zeros. The old reference being zeros implies the branch doesn't exist locally. This makes it possible to create a master branch in the example repo by pushing a new commit to master:

$ git push origin master
Counting objects: 3, done.
Writing objects: 100% (3/3), 236 bytes | 0 bytes/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To /home/example/example.git
 * [new branch]      master -> master

However subsequent commits made directly to master will still be rejected:

$ git commit test.txt -m "Update test file"
[master b4bf80d] Update test file
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git push origin master
Counting objects: 5, done.
Writing objects: 100% (3/3), 268 bytes | 0 bytes/s, done.
Total 3 (delta 0), reused 0 (delta 0)
remote: Don't commit directly to master!
remote: error: hook declined to update refs/heads/master
To /home/example/example.git
 ! [remote rejected] master -> master (hook declined)
error: failed to push some refs to '/home/example/example.git'

The second problem is fixed by comparing the new reference to references in existing local branches. This allows master to be updated if the commit is already present in an existing branch:

$ git checkout -b topic_branch
Switched to a new branch 'topic_branch'
$ git push origin topic_branch
Counting objects: 5, done.
Writing objects: 100% (3/3), 268 bytes | 0 bytes/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To /home/example/example.git
 * [new branch]      topic_branch -> topic_branch
$ git push origin master
Total 0 (delta 0), reused 0 (delta 0)
To /home/example/example.git
   5b1235d..b4bf80d  master -> master

Obviously this also means you can effectively update master by pushing a temporary branch. However the hook should be enough to prevent people habitually working on master.