Introduction to git hooks

Just beneath the surface of git’s version control system lies a rich framework for workflow development. At its heart are a sequence of events that are fired as changes are prepared, committed, and pushed. As each event is dispatched, git checks for–and executes–a corresponding hook. By implementing these in a development environment, we can automate tasks like:

  • verifying new changes against existing style guidelines
  • verifying and updating dependencies
  • enforcing history structure and hygiene

Let’s take a look.

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

Each hook is an executable shell script. In true git style, the batteries are included but optional: we’ll need to trim the trailing .sample from each hook’s filename to enable it.

Take pre-commit, which runs immediately before a commit would be created. The sample hook validates that new commits are free of whitespace errors (e.g. trailing newlines) and don’t depend on platform-dependent (non-ASCII) filenames. Let’s see it in action by moving pre-commit.sample to pre-commit and trying to commit a file with a whitespace error:

$ mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
$ 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, 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.

Managing hooks

As teams grow, workflow consistency is maintained by cutting and pasting hooks between repositories. This isn’t ideal, but since hooks live outside the index, each developer must manage their own copy. And since hooks’ changes aren’t tracked, small improvements made by one developer have no obvious path for delivery to others. We could contrive a workflow that would check the hooks in–probably something involving submodules, a monorepo, or at least some extra shell scripts around the edges–but odds are that the problem has already been solved!

Several stable, open-source projects would be happy to help manage git hooks on our behalf:

Conclusion

Git hooks are an unobtrusive, built-in tool for developing a consistent git workflow. 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.

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.