Introduction to Git Hooks

Git’s lifecycle hooks aren’t its best-known feature, but they’re a simple, valuable tool for workflow automation. They work as you might expect: when changes are committed, pushed, pulled, merged, and applied, git will optionally execute scripts for the corresponding event.

While the hook scripts can contain anything you like, I’ve found them useful for:

  • enforcing style guidelines
  • verifying and updating dependencies
  • pre-populating commit messages
  • enforcing good git hygiene

Sound good? All we need to get started is git and whatever scripting language happens to be in the neighborhood.

Getting started

Every new git repository comes into the world with a set of predefined sample hooks.

$ git --version
git version 2.7.4
$ mkdir foo && cd foo
$ git init
$ ls -1 .git/hooks/
applypatch-msg.sample
commit-msg.sample
post-update.sample
pre-applypatch.sample
pre-commit.sample
prepare-commit-msg.sample
pre-push.sample
pre-rebase.sample
update.sample

In typical git style, the batteries are included, useful, and optional: the sample implementation of pre-commit, for instance, rejects new commits that contain whitespace errors (e.g. trailing newlines) or platform-dependent (non-ASCII) filenames. We can enable it (or any of the other sample) hooks by simply dropping the file extension.

# "activate" the sample `pre-commit` hook
$ mv .git/hooks/pre-commit.sample .git/hooks/pre-commit

# sample hooks are executable by default, but any custom
# scripts will need the executable bit set:
$ chmod +x .git/hooks/pre-commit

Now, let’s see the hook in action by trying to commit a file with a whitespace error.

# add an invalid file
$ echo file with trailing newline > bar
$ echo >> bar
$ git add . && git commit -m 'Adds invalid file'
bar:2: new blank line at EOF.

In addition to the “blank line” warning, we can also see that the hook has exited with status 1. More importantly for our workflow, the attempted commit was aborted:

$ echo $?
1
$ git show
fatal: your current branch 'master' does not have any commits yet

Just like that, git’s sample pre-commit hook is now protecting this repository against simple formatting errors.

The basics

Our quick tête-à-tête with the sample hook outlined the basics:

  1. hooks are implemented in predefined scripts in .git/hooks
  2. hook scripts must be executable
  3. hooks fail with non-zero exit statuses

Testing a hook

Hooks are just scripts. To test them, either by hand or through an automated testing system, all we need is a working git index and a shell that can run the hook. Testing the pre-commit sample against a clean index, for instance, we should see that everything is 'OK':

$ ./.git/hooks/pre-commit && echo 'OK'

For the handful of hooks that expect parameters, we can simply pass arguments in directly. commit-msg, for example, expects an in-progress commit message as its only argument:

$ echo 'Corrects a criminal complication' > /tmp/commit.msg
$ ./.git/hooks/commit-msg /tmp/commit.msg && echo 'OK'

In both cases the test recipe remains the same. Configure the git index and script parameters in a breaking scenario, run the hook, and assert a non-zero exit status. Fix the index, re-run the hook, and watch for a happy 0.

Tuning hooks

We’ve set up and tested hooks. They work; they’re right; and it’s time to make them fast. “Fast” may not be a concern for hooks like commit-msg, but sophisticated linting in a pre-commit hook can add up quickly in a larger project. Validation takes time, more validation takes more time, and it should go without saying that hooks shouldn’t block us when we’re trying to commit code. Lucky us! git and a garden-variety unix shell are quite capable of pruning a crowded git repository down to the relevant pieces.

First, we are usually only interested in validating what has changed. All we need to decide is what to compare to–a commit, branch, or other git ref. This could be HEAD (in a pre-commit hook, HEAD will point to the previous commit), a main branch like master, or anything else useful in the context of the project.

Once we have a basis for comparison, our hooks can invoke git diff --cached to pick through the change we’re about to commit. To simplify things further, we’ll only consider whole files, using --name-only to skip over the details within the diff.

$ echo 'hello world' > readme.txt && git add readme.txt
$ git diff --cached --name-only
readme.txt

Useful! Let’s alias it in a bash function for ease-of-use later on in the hook:

# within the hook
changed-files () {
  git diff --cached --name-only "$@"
}

Now we can do things like:

# later on, still within the hook
changed_css_files=$(changed-files *.css)

No more need to validate CSS style across the entire stylesheet directory–our hooks can now conditionally target only the files that have changed.

Conclusion

By default, hooks live outside the git index. Since larger projects benefit from keeping consistent expectations between developers, we’ll often want a way to keep our hooks in sync. That’s a topic for another day, but even rolling out personal hooks can make a marked improvement on developer quality of life.

However they’re deployed, git hooks are an unobtrusive, built-in tool for building consistent git workflows. Writing and testing them is easy–they’re just scripts!–and with just a bit of attention we can keep them running fast and true.


How-Tos

Practical tutorials and hands-on guides

Read all

Let’s keep in touch

Reach out on Twitter or subscribe for (very) occasional updates.

Hey, I'm RJ: digital entomologist and intermittent micropoet, writing from the beautiful Rose City.