How Git hooks made me a better (and more lovable) developer

  • Wisdom
  • Git

I've, for a very long time, been telling myself “I really should enable some Git hooks here to (run unit tests|spell check my commit message|some other task)”.

But, as usual, I've also, for a very long time, postponed this enabling of Git hooks for reasons we all know: laziness, too busy, procrastination.

Then, I finally decided it was time I dug into it.

Git Hooks Allow You To Enforce Best Practices (For Yourself)

We all know (and love) best practices. We just don't always have the time (or mental energy) to apply them. It's laziness on our part, but what can you do ?

Well, for a start, you can create a Git hook.

A Short Note On Git Hooks

If you're not familiar with Git hooks, they basically are small shell scripts that can be automatically run on certain events, like a commit, a push, etc.

There are a lot of them. You can find information on existing hooks, what they do and how to implement them here.

To create (and enable) a Git hook, create a file with the hook name in .git/hooks. So for a pre-commit hook, you would create .git/hooks/pre-commit.

Next, open the file and (on Unix systems), add


#!/bin/bash

at the top.

Finally, Git will not invoke the hook if it's not executable. Meaning you need to call


chmod +x .git/hooks/pre-commit

to “enable” it.

Pre-commit Hooks, Or How To Become A Better Developer (at least looking at your commit history)

The most useful, in my opinion, is pre-commit. Pre-commit hooks are run before the commit is made. This means you can use git add normally, but as soon as you run git commit (or git commit -am), this hook is invoked. If it exits with something (like 1), Git will think there's an error and abort the commit.

How's this useful ?

For starters, say you develop in PHP. You probably never want to commit code with PHP syntax errors, right ? But it probably has happened, and probably will happen again. So...

Check PHP Syntax Errors Before Committing

This is a nifty pre-commit hook I've set up (and set up for all PHP projects).


git diff --cached --name-only | while read FILE; do
if [[ "$FILE" =~ ^.+(php|inc|module|install|test)$ ]]; then
    # Courtesy of swytsh from the comments below.
    if [[ -f $FILE ]]; then
        php -l "$FILE" 1> /dev/null
        if [ $? -ne 0 ]; then
            echo -e "\e[1;31m\tAborting commit due to files with syntax errors.\e[0m" >&2
            exit 1
        fi
    fi
fi
done || exit $?

Let's go through these, line by line.


git diff --cached --name-only | while read FILE; do

This tells Git to return the files that have changed, but only their names. We pipe the result to a while loop, which goes over each file name, assigning them to a variable called FILE.


if [[ "$FILE" =~ ^.+(php|inc|module|install|test)$ ]]; then

We then pattern match the file name. If it ends in one of the listed extensions (note I'm listing Drupal file extensions as well), we...


if [[ -f $FILE ]]; then

Check if it exists (thanks to swytch for catching this). When removing a file from Git, git diff --cached --name-only will still list those removed files. However, the PHP linter will still try to test it, which will result in a Fatal Error. Which brings us to...


php -l "$FILE" 1> /dev/nul

Pass it through the php linter, pumping the result straight to /dev/null.


if [ $? -ne 0 ]; then

If there's a result in memory, it means we have a PHP error.


echo -e "\e[1;31m\tAborting commit due to files with syntax errors.\e[0m" >&2

We output an error message. Note the -e and \e[1;31m, which will output the message in red.


exit 1

We exit with “something”. Anything would do — we're just telling Git to abort the commit.


        fi
    fi
fi
done || exit $?

This is obvious, right ? Note the || exit $? though; this is to prevent a small issue you might run into with certain shells. Because we use a pipe to trigger our while loop, we create a new shell process. Our exit 1 will exit the sub-shell routine, but not the “parent” shell. See this Stackoverflow question for more information. Thanks to Jan van Dijk for catching that.

This hook will make sure you will never commit invalid PHP code again. Pretty neat, huh ?

Run Your Unit Tests

What do you mean, “I don't have unit tests” ?

If you have unit tests for your code, you probably never want to commit code that does not pass your tests. Say your tests are written for PHPUnit. This will call PHPUnit and abort the commit if the tests fail.


git diff --cached --name-only | while read FILE; do
if [[ "$FILE" =~ ^.+(php|inc|module|install|test)$ ]]; then
    /home/wadmiraal/.composer/vendor/bin/phpunit 1> /dev/null
    if [ $? -ne 0 ]; then
      echo -e "\e[1;31m\tUnit tests failed ! Aborting commit.\e[0m" >&2
      exit 1;
    fi
fi
done || exit $?

I won't go over each line, but the important one is


/home/wadmiraal/.composer/vendor/bin/phpunit 1> /dev/null

I installed PHPUnit through composer. I have a phpunit.xml file in my project configuring my test suites. If the tests fail, the commit is aborted and a red warning spit out. Pretty cool.

Remove Debug Calls

Sometimes we forget some tracer code we used locally (like dpm calls in Drupal, or console.log in Javascript). Sometimes this is just a performance problem, but other times it actually breaks the system because some debug library has not been included in the production code.

This pre-commit hook checks for certain patterns in code and warns me about it. Note that it does not block the commit like before, because we sometimes want to commit debug code. It only warns me.


git diff --cached --name-only | while read FILE; do
if [[ "$FILE" =~ ^.+(php|inc|module|install|test)$ ]]; then
    RESULT=$(grep "dpm(" "$FILE")
    if [ ! -z $RESULT ]; then
      echo -e "\e[1;33m\tWarning, the commit contains a call to dpm(). Commit was not aborted, however.\e[0m" >&2
    fi
fi
done

I won't go over each line this time, but the


RESULT=$(grep "dpm(" "$FILE")

line calls grep and checks for the dpm( pattern (Drupal debug code). If it finds it, it will print a warning message so I'm aware I'm committing a call to dpm().

Follow Coding Standards

This is one of my favorites. I love coding standards, I always try to adhere very strictly to one. Doing a lot of Drupal development, I try to stick to its standards as closely as possible. But I always seem to forget some rule. Not anymore.

Install PHP Code Sniffer And The Drupal Sniffer

PHP Code Sniffer is a handy tool that will check your code (not just PHP) and tell you where you do not comply with a given standard.

Drupal has its own sniffer implementation. It ships with the Coder module. You don't need the module itself, but the sniffer it contains (in the coder_sniffer/Drupal subfolder).

Install PHP Code Sniffer, and clone the Coder Git repo somewhere on your system.

Create The Hook

Note the paths to the phpcs executable and the path to the Drupal sniffer. Change these accordingly.


git diff --cached --name-only | while read FILE; do
if [[ "$FILE" =~ ^.+(php|inc|module|install|test)$ ]]; then
    /home/wadmiraal/.composer/vendor/bin/phpcs --standard=/home/wadmiraal/.drupal/modules/coder/coder_sniffer/Drupal "$FILE" 1> /dev/null
    if [ $? -ne 0 ]; then
        echo -e "\e[1;33m\tWarning, some files do not respecting the Drupal coding standards. Commit was not aborted, however.\e[0m" >&2
    fi
fi
done

Again, I won'to go over each line, the important one being


/home/wadmiraal/.composer/vendor/bin/phpcs --standard=/home/wadmiraal/.drupal/modules/coder/coder_sniffer/Drupal "$FILE" 1> /dev/null

Here we check all files with phpcs, providing the Drupal sniffer. Here again, I don't abort the commit. I can't always follow Drupal's coding-standards to the letter, but at least I am aware of it and can stick to it as much as possible.

Putting It All Together

If you just copy-and-paste all the above, you will notice your hook doesn't behave as expected. That is because the different invocations will override each other. So, a PHP syntax error might still get commited. If you want combine these, use if/else statements to group them, executing blocking conditions first and on their own (meaning, if they fail, abort and exit) and non-blocking conditions last and in sequence.

So my above example looks like this:


#!/bin/bash

git diff --cached --name-only | while read FILE; do
if [[ "$FILE" =~ ^.+(php|inc|module|install|test)$ ]]; then
    if [[ -f $FILE ]]; then
        php -l "$FILE" 1> /dev/null
        if [ $? -ne 0 ]; then
            echo -e "\e[1;31m\tAborting commit due to files with syntax errors.\e[0m" >&2
            exit 1
        fi
    fi
fi
done || exit $?

if [ $? -eq 0 ]; then
    /home/wadmiraal/.composer/vendor/bin/phpunit 1> /dev/null
    if [ $? -ne 0 ]; then
        echo -e "\e[1;31m\tUnit tests failed ! Aborting commit.\e[0m" >&2
        exit 1;
    else
        /home/wadmiraal/.composer/vendor/bin/phpcs --standard=/home/wadmiraal/.drupal/modules/coder/coder_sniffer/Drupal "$FILE" 1> /dev/null
        if [ $? -ne 0 ]; then
            echo -e "\e[1;33m\tWarning, some files do not respecting the Drupal coding standards. Commit was not aborted.\e[0m" >&2
        fi

        RESULT=$(grep "dpm(" "$FILE")
        if [ ! -z $RESULT ]; then
            echo -e "\e[1;33m\tWarning, the commit contains a call to dpm(). Commit was not aborted, however.\e[0m" >&2
        fi
    fi
fi

Commit-msg, Or Why Your Colleagues Will Love You (for your commit messages)

It's very annoying to read through bad commit messages. It's even worse when these commit messages are littered with errors. This is why I use Aspell to spellcheck my commit messages.

If a message contains an error, I can use git commit --amend to change it. I also find that, because this forces me to stop and think about my commit message, I'm encouraged, when amending, to rephrase certain aspects to make the commit clearer.

This requires Aspell to be installed on your system.


ASPELL=$(which aspell)
if [ $? -ne 0 ]; then
    echo "Aspell not installed - unable to check spelling" >&2
    exit
else
    WORDS=$($ASPELL list < "$1")
fi
if [ -n "$WORDS" ]; then
    echo -e "\e[1;33m\tPossible spelling errors found in commit message. Use git commit --amend to change the message.\n\tPossible mispelled words: " $WORDS ".\e[0m" >&2
fi

Commit-msg cannot prevent a commit, but can warn the user that's something's wrong. Which is what I do here.

Gotta Catch'em All !

What about you ? Any Git hooks you use ?

Enjoyed this post? Grab the RSS feed or follow me on Twitter!

Special thanks to:

Vincent Robinson
swytsh

for contributing to this post.

Found a typo ? Correct it, submit a pull-request and get credited here!