An Introduction to Git Hooks

Andrew Udvare
Tweet

Git hooks are simple scripts that run before or after certain actions. They are useful for a variety of tasks, but primarily I find them useful for client-side validation so simple mistakes can be prevented. For example, you can test syntax on files being commited, you can even have tests run. I have written hooks that validate Twig syntax, run JSHint to a standard, and a lot more.

Git hooks are also extremely simple by design. Git will run these hooks if the script is executable and Git will allow the action (e.g. commit or push) to occur as long as the hook exits with no errors (status 0). Hooks can be written in any language the environment can work with.

There are two types of hooks:

  • Client-side – These run on the developer’s system
  • Server-side – These run on the server hosting the Git repository

Server-side hooks will not be covered in this article. However, do note that if your project is on a service like GitHub, server-side hooks are generally not applicable. On GitHub, the equivalent to server-side hooks is to use services and Webhooks which can be found in your project settings.

The Hook Files

Every repository including those you clone by default will have example hooks in the .git/hooks directory:

git clone git@github.com:symfony/symfony.git
cd symfony
ls .git/hooks

In that directory, you will see something like:

applypatch-msg.sample
commit-msg.sample
post-update.sample
pre-applypatch.sample
pre-commit
pre-commit.sample
prepare-commit-msg.sample
pre-push.sample
pre-rebase.sample
update.sample

We will focus on the pre-commit hook which runs prior to allowing a commit.

An Example Hook: Validating PHP Syntax

We will begin with a very simple hook, written in Bash, that validates PHP code being committed has valid syntax. This is to prevent a “quick” but broken commit from happening. Of course I discourage “simple commits” that have little to no testing, but that does not mean they will not happen.

In .git/hooks we can start a new file called pre-commit. It must have permissions to execute:

cd .git/hooks
touch pre-commit
chmod +x pre-commit

You can use your favourite editor to begin writing. First we need the shebang. My favoured way is to use /usr/bin/env as this uses the correct path to the application we want rather than a hard-coded and possibly invalid path. For now we will have it continuously fail so we can easily test.

#!/usr/bin/env bash
# Hook that checks PHP syntax

# Override IFS so that spaces do not count as delimiters
old_ifs=$IFS
IFS=$'\n'

# Always fail
exit 1

PHP has a useful option for syntax validation: -l. It takes a single file argument, so we will have to loop through whatever PHP files are being changed. For simplicity we’ll assume any PHP files being committed always end in .php. Since the hook is run from the root of the repository, we can use standard Git commands to get information about the changes, like git status.

Above the #Always fail line we can use the following to get all PHP files being modified:

php_files=$(git status --short | grep -E '^(A|M)' | awk '{ print $2 }' | grep -E '\.php$')

Explanation:

  • php_files= In Bash assignment is done with no delimiter but note that referencing a variable requires the $ delimiter
  • $() is syntax for ‘get output’. Quotes are not required to use this.
  • grep is being used to check for added (A) and modified files (M)
  • Awk is being used here to print $2. A complete git status --short line has extra space and extra data at the beginning, so we want to remove that. Awk also performs automatic stripping.
  • grep is again being used, but now is checking to make sure the lines end in .php

Now we can verify each file with a for loop:

for file in $php_files; do
  if ! php -l "$i"; then
    exit 1
  fi
done

This may seem a bit strange but ! php -l "$i" (note the quotes to avoid issues with spaces) is actually checking for a return value of 0, not true or any of the sort of values we normally expect in other languages. Just for reference, the approximately equivalent PHP code would be:

foreach ($php_files as $file) {
  $retval = 0;
  $escapedFile = escapeshellarg($file);
  exec('php -l ' . $escapedFile, $retval); // $retval passed in as out parameter reference
  if ($retval !== 0) {
    exit(1);
  }
}

I made a bad change to src/Symfony/Component/Finder/Glob.php on purpose to test this and the output from git commit -m 'Test' is like so:

PHP Parse error:  syntax error, unexpected 'namespace' (T_NAMESPACE) in src/Symfony/Component/Finder/Glob.php on line 12

Parse error: syntax error, unexpected 'namespace' (T_NAMESPACE) in src/Symfony/Component/Finder/Glob.php on line 12

Errors parsing src/Symfony/Component/Finder/Glob.php

I made the loop exit the entire script as early as possible and this ultimately may not be what we want. We may in fact want a summary of things to fix as opposed to having to continue to try to commit. Anyone would easily get frustrated eventually and might even learn to use git commit --no-verify to bypass the hook altogether.

So instead, let’s not exit on the error with php -l but I still would like to keep things easy to read:

for file in $php_files; do
  php_out=$(php -l "$file" 2>&1)
  if ! [ $? -eq 0 ]; then
    echo "Syntax error with ${file}:"
    echo "$php_out" | grep -E '^Parse error'
    echo
  fi
done

Here we capture the output for php -l (and force standard error output to standard output). We check the exit status of php -l using the special variable $? (which is the exit status code) and the operator -eq. We state that a syntax error occurred (note the use of ${} for a variable in a string). Finally, we give the relevant line for error to keep output a little more brief (grepping for '^Parse error'), and we give one blank line to keep this a little more readable.

I made two bad modifications and the output for an attempt at a commit looks like this:

Syntax error with src/Symfony/Component/Finder/Finder.php:
Parse error: syntax error, unexpected '}' in src/Symfony/Component/Finder/Finder.php on line 118

Syntax error with src/Symfony/Component/Finder/Glob.php:
Parse error: syntax error, unexpected 'namespace' (T_NAMESPACE) in src/Symfony/Component/Finder/Glob.php on line 12

Now the course of action is to fix these problems, test, and try to commit again.

To complete the hook script, remove the exit 1 at the bottom of the script. Try to commit valid PHP files and it should work as normal.

Sharing Hooks

Hooks are not distributed with your project nor can they be automatically installed. So your best course of action is to create a place for you hooks to live (could be in the same repository) and tell your collaborators to use them. If you make this easy for them, they are more likely to do so.

One simple way to do this would be to create a hooks directory and a simple installer install-hooks.sh that links them (rather than copying):

#!/usr/bin/env bash
for i in hooks/*; do ln -s "${i}" ".git/hooks/${i}"; done

Anyone who clones your project can simply run bash install-hooks.sh after cloning.

This also has the benefit of keeping your hooks under version control.

Other Hooks

  • prepare-commit-msg – Provide a default commit message if one is not given.
  • commit-msg – Commit message validation.
  • post-commit – Runs after a successful commit.
  • pre-push – Runs before git push after the remote is verified to be working. It takes 2 arguments: the name of the remote, and the URL to it.
  • pre-rebase – Runs before git rebase.
  • post-checkout – Runs after a successful checkout.
  • post-merge – Runs after a successful merge.

These hooks generally work the same as pre-commit although they take in arguments. One use case for post-checkout is to ensure that a file always gets proper permissions (because Git only tracks executable, not executable and symbolic link):

#!/usr/bin/env bash
# Make sure only I can read this file
chmod 0600 my-file-with-secrets

For commit-msg you may want to ensure all commit messages conform to a standard, like [subproject] Message. Here is one in PHP:

#!/usr/bin/env php
<?php
// The message is passed as the the last argument.
$message = file_get_contents($argv[count($argv) - 1]);
if (!preg_match('/^\[[a-z_\-]+\]\s[A-Z]/', $message)) {
  echo 'Message must be of format: [subproject] Message';
  exit(1);
}
?>

Conclusion

Git hooks are a powerful means to automate the workflow of your project. You can validate code, commit messages, ensure environment is proper, and a whole lot more. Is there something interesting you are using Git hooks for? Let us know in the comments!

Free book: Jump Start HTML5 Basics

Grab a free copy of one our latest ebooks! Packed with hints and tips on HTML5's most powerful new features.

  • Joseph Slack

    Well done. Very informative and well written. This should help people get up and running with hooks in very short time. I look forward to reading more that you may write.

  • Tatsh

    Thanks for commenting and for the link.