Hooking Git

3 minute read

Hooks are an amazingly useful feature of git. Once your repository gets to a medium size, you may want to make a few quick checks whenever you commit. For example, you could run a linter, check for TODOs, or even auto-send emails! A “hook” is just a simple script that runs during various important times, like before git commit.

When to Run

Hooks are bash scripts that exist within a repository. If you go into .git/hooks, you will probably already see a few examples. Each hook has a different filename depending on when it is run. For example, pre-commit will run when calling git commit, before the files are committed. All the types can be found in that hooks folder, and there are more in-depth guides online.

Most people agree that pre-commit is the most useful. This script is run when git commit is called. However, if it throws an error, the error will be passed up to git commit, which will abort. Because of this, you can write validations and abort the commit if they do not pass.

Creation

To create a script, just remove the “.example” section of the filename. Alternatively, you can create a new file with the name of the hook you would like. You cannot duplicate filenames, so include all functionality you need for one hook into one file. The contents of the files should be written in bash syntax.

Secondly, you need the file set as runnable (so bash can call it). Run chmod +x .git/hooks/pre-commit, or whatever the filename is.

Finally, it can be useful to know what arguments git will pass to the script. For example, commit-msg will pass one argument, which you can access in the script with "$1".

Example

It is common practice to write #todo: in code, but I also use the phrase #donotsubmit. This is attached to functionality that I don’t want committed, like print statements or massive comment blocks. Let’s walk through an example where I block a commit if any of its files contain this phrase.

git diff --cached --name-only | while read FILE; do
  RESULT=$(grep -i "donotsubmit" "$FILE")
  if [[ ! -z $RESULT ]]; then
    printf "\e[1;31m\tError, the commit contains a DONOTSUBMIT.\e[0m\n" >&2
    exit 1;
  fi
done

I am not a bash master, but let’s go over this line-by-line.

git diff --cached --name-only

This returns a list of files that have been added. You can check this by calling git add and then this command.

| while read FILE; do

This passes the result of the previous command (it “pipes” it) into a while loop that will read each row of the output.

RESULT=$(grep -i "donotsubmit" "$FILE")

Looks for the string “donotsubmit.” The -i flag indicates case insensitivity.

if [ ! -z $RESULT ]; then

Checks if the string is empty.

printf "\e[1;31m\tError, the commit contains a DONOTSUBMIT.\e[0m\n" >&2
exit 1;

Prints an error and exits with a value of 1. A non-zero exit value indicates a failure.

Now if I run git commit while a file has “donotsubmit” in it, it will fail and print to console.

Additionally, if I want to add a check for another phrase, but only throw a warning, I can do something like this:

git diff --cached --name-only | while read FILE; do
  RESULT=$(grep -i "fixme" "$FILE")
  if [[ ! -z $RESULT ]]; then
    printf "\e[1;33m\tWarning, the commit contains a FIXME.\e[0m\n" >&2
  fi
done

If I want to combine the two, I just put them in the same file.

Closing Thoughts

This can be extended even further, of course. You can run a linter or tests, and call all sorts of outside functions.

One nice thing about this is that you do not necessarily have to write it in bash. For example, if you want to use Python to run some complex functionality, you can just use the bash to call an external Python file.

The next stage of this would be to have tests that run on your Github origin whenever someone pushes or makes a pull request. This is outside the scope of this blog, but I would encourage you to look into Travis CI, or another continuous integration tool.