Here's what we want:

git push

Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Delta compression using up to 8 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 274 bytes | 274.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0)

remote:
remote: The following files did't pass the code formatting rules:
remote:     junky.js
remote:
remote: error: hook declined to update refs/heads/master

When someone commits junk files that don't pass the code linter (i.e. go vet, jshint) or formatter (i.e. go fmt, Prettier), we want to hard reject their commits - on the git server.

A simple starter pack

Typically on your server you'll have a "bare" my-project.git instead of a project/.git.

The layout may look like this:

/srv/git-repositories/my-project.git
├── HEAD
├── branches
├── config
├── description
├── hooks
│   ├── post-receive
│   ├── pre-receive
│   └── update
├── index
├── info
│   └── exclude
├── logs
│   └── HEAD
├── objects
│   ├── 00
│   │   └── 00000000000000000000000000000000000000
│   ├── 4b
│   │   └── 825dc642cb6eb9a060e54bf8d69288fbee4904
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   ├── beta
    │   └── master
    └── tags
        └── v1.0.0

The 3 files of particular interest are these server-side git hooks:

* post-receive
* pre-receive
* update

Although it's certainly a much easier term to search for, the pre-receive hook is only useful for accepting or rejecting all changes to all branches that are being pushed.

The update hook, on the other hand, is more granual (and easier to use).

Here's a flawed, but useful, update hook for server-side code formatting checks that uses Prettier (though you could use anything), and has all of the pieces you need to get started:

(or skip ahead to the fuller, better script way down below, if you prefer)

ref_name=$1
new_rev=$3

# only check branches, not tags or bare commits
if [ -z $(echo $ref_name | grep "refs/heads/") ]; then
  exit 0
fi

# don't check empty branches
if [ "$(expr "${new_rev}" : '0*$')" -ne 0 ]; then
  exit 0
fi

# Checkout a copy of the branch (but also changes HEAD)
my_work_tree=$(mktemp -d -t git-work-tree.XXXXXXXX) 2>/dev/null
git --work-tree="${my_work_tree}" --git-dir="." checkout $new_rev -f >/dev/null

# Do the formatter check
echo "Checking code formatting..."
pushd ${my_work_tree} >/dev/null
prettier './**/*.{js,css,html,json,md}' --list-different
my_status=$?
popd >/dev/null

# reset HEAD to master, and cleanup
git --work-tree="${my_work_tree}" --git-dir="." checkout master -f >/dev/null
rm -rf "${my_work_tree}"

# handle error, if any
if [ "0" != "$my_status" ]; then
  echo "Please format the files listed above and re-commit."
  echo "(and don't forget your .prettierrc, if you have one)"
  exit 1
fi

It's also very important that you set the exec bit, otherwise the script will be silently skipped:

chmod -R a+x /srv/git-repositories/my-project.git/hooks

If the script does run to completion, then any exit status other than 0 (success) will cause the push to be rejected.

A better version

Although the script above will work, there are a few things that could be improved:

  • Leave HEAD as-is
  • Better user feedback
  • Make easier to use with any formatter
  • Better error detection
  • Handle tags (not just branches)

I thought about doing a full play-by-play showing how I built this up... but I opted for just having better comments instead.

There's a lot of bash-fu here. Most of it I understand pretty well, but some of it came from good ol' fashioned StackOverflow-Driven-Development.

I tried to keep it as simple as reasonable, but no simpler - and there's some explanation down below.

#!/usr/bin/env bash
set -u
set -e

ref_name=$1
new_rev=$3

# Here you can use whatever formatter you like
# and set up the error (or success) output as you wish
format() {
    local my_work_tree=$1
    pushd $my_work_tree >/dev/null

    # briefly allow non-zero exit status
    set +e
    local my_diffs=$(2>&1 /usr/local/bin/prettier './**/*.{js,css,html,json,md}' --list-different | grep -v 'No matching files')
    set -e

    # Show a friendly error message to the git user downstream (if there are files to fix)
    if [ -n "$my_diffs" ]; then
        >&2 echo ""
        >&2 echo "Please run prettier on the following files (and double check your .prettierrc):"
        echo "$my_diffs" | while read my_word; do
            >&2 echo "  $my_word"
        done
        >&2 echo ""
        >&2 echo "Example:"
        >&2 echo "  npm install -g prettier"
        >&2 echo "  prettier './**/*.{js,css,html,json,md}' --write"
        >&2 echo ""
    fi

    popd >/dev/null

    # This is the "return" value
    echo $my_diffs
}

# This is all pretty generic - just checking out files to lint/format and running said formatter
case "${ref_name}" in
refs/heads/*)
    # match empty commit 0000000000000000000000000000000000000000
    # all deletes should be successful (no files to check against)
    if [ "$(expr "${new_rev}" : '0*$')" -ne 0 ]; then
        exit 0
    fi

    # We don't want a race condition if multiple people push while a check is in progress
    # For this reason we first checkout all of the files in HEAD and the copy over the changed files.
    # We also need to remove any deleted files.
    #
    # ex: /tmp/git-work-tree.abc123xyz
    my_work_tree=$(mktemp -d -t git-work-tree.XXXXXXXX) 2>/dev/null
    git --work-tree="${my_work_tree}" --git-dir="." checkout -f >/dev/null
    my_changes=$(git --work-tree="${my_work_tree}" --git-dir="." diff --name-status HEAD $new_rev)
    if [ -n "$(echo "$my_changes" | grep -e "^A")" ]; then
        echo "$my_changes" | grep -e "^A" | cut -f 2 | \
            xargs git --work-tree="${my_work_tree}" --git-dir="." checkout $new_rev --
    fi
    if [ -n "$(echo "$my_changes" | grep -e "^M")" ]; then
        echo "$my_changes" | grep -e "^M" | cut -f 2 | \
            xargs git --work-tree="${my_work_tree}" --git-dir="." checkout $new_rev --
    fi
    if [ -n "$(echo "$my_changes" | grep -e "^D")" ]; then
        echo "$my_changes" | grep -e "^D" | cut -f 2 | \
            xargs git --work-tree="${my_work_tree}" --git-dir="." rm -rf -- >/dev/null
    fi

    # Now we run the formatter, do some cleanup, and error out if needed
    my_failure=$(format $my_work_tree)
    rm -rf "${my_work_tree}"
    if [ -n "$my_failure" ]; then
        exit 1
    fi
;;
refs/tags/*)
    # allowing tags to be made regardless of formatting (such as old versions)
    exit 0
;;
*)
    # allowing all other things (not sure what they are though, TBH)
    exit 0
;;
esac

Here's the almanac:

set -u      # don't allow unbound variables / guard against typos
set -e      # immediately exit if any sub-command has a non-zero exit code
set +e      # continue despite failing commands (default)

local foo   # declare a variable local to a function

>&2         # redirect stdout to stderr
2>&1        # redirect stderr to stdout

pushd       # like cd, but track a stack of directories
popd        # like cd -, but can pop directory stack many times

mktemp -d -t git-work-tree.XXXXXXXX # create a temp directory using the template string,
                                    # replacing Xs with random characters

xargs       # for each of the preceding newline-delimited strings,
            # tack one on to the following command as an argument, and repeat

prettier './**/*.{js,css,html,json,md}' # quote the file pattern glob to pass to prettier, not bash

Well, that's that and I hope you learned something. 🙂

One more thing

You might want to check out Automated Git Deploys and DIY Multi-User Git Deploys as well.


By AJ ONeal

If you loved this and want more like it, sign up!


Did I make your day?
Buy me a coffeeBuy me a coffee  

(you can learn about the bigger picture I'm working towards on my patreon page )