We all strive to achieve great quality code. Every language allows us to run some quality checks or automatic unit tests. But even best tests won't help, if they aren't run often.

Remember! If something takes too much time or effort, people will avoid it!

Solution?

Automate all the things

We can reverse that! Let's make automatic tests effortless, and add additional overhead for avoiding them.

Git hooks

Not everyone knows that, but git allows us to inject many helpful hooks into our workflow. Full list can be found here, but we'll be interested in just two of them - pre-commit and pre-push. So, what exactly is a git hook?

Each hook is a single executable script, preferably with a shebang line. Git looks for hooks inside .git/hooks directory. Besides having right name, script have to be executable (chmod +x script_name) to be run.

pre-push and pre-commit hooks

These two hooks are the main heroes of this blog post. As their names suggest, they are run by git before pushing update to server and before creating a commit. Let's look closer.

  • pre-commit can change commit message or prevent commit
  • pre-push can prevent push to remote server

We're going to use these hooks to prevent bad commits from going to server and keep lazy developers (we're all lazy ;) ) on a leash. We can also create a CI server to achieve that, but it's much more complicated and feedback loop takes longer.

Test script

First, we need script that will run our tests and checks. Let's create one!

# Let's create empty git repo
mkdir git-hooks-test
cd git-hooks-test
git init

# Let's create directory for scripts 
mkdir scripts
# use your favorite editor to create .bash file
vim scripts/run-tests.bash 

Example content of a scripts/run-tests.bash file:

#!/usr/bin/env bash

# if any command inside script returns error, exit and return that error 
set -e

# magic line to ensure that we're always inside the root of our application,
# no matter from which directory we'll run script
# thanks to it we can just enter `./scripts/run-tests.bash`
cd "${0%/*}/.."

# let's fake failing test for now 
echo "Running tests"
echo "............................" 
echo "Failed!" && exit 1

# example of commands for different languages
# eslint .         # JS code quality check
# npm test         # JS unit tests
# flake8 .         # python code quality check
# nosetests        # python nose 
# just put your usual test command here 

Ok, so we have script running our checks, returning error when something fails. Now, we need to install it.

Hook and install script

There's one problem. Files stored inside .git directory are not kept in repository. We can deal with it by creating our hook in scripts and creating symlink from .git/hooks directory. Also this will keep our hook always in sync.

Let's create scripts/pre-commit.bash hook.

#!/usr/bin/env bash

echo "Running pre-commit hook"
./scripts/run-tests.bash

# $? stores exit value of the last command
if [ $? -ne 0 ]; then
 echo "Tests must pass before commit!"
 exit 1
fi

And final step is to create scripts/install-hooks.bash script

#!/usr/bin/env bash

GIT_DIR=$(git rev-parse --git-dir)

echo "Installing hooks..."
# this command creates symlink to our pre-commit script
ln -s ../../scripts/pre-commit.bash $GIT_DIR/hooks/pre-commit
echo "Done!

Let's make all new scripts executable

chmod +x scripts/run-tests.bash scripts/pre-commit.bash scripts/install-hooks.bash 

Ok, we're all set! Feel free to install our hook (everyone in your team has to do that, but only once)

./scripts/install-hooks.bash

Now, every time when someone will try to create a commit, all tests must pass to allow that.

git add .
git commit -m "test"
>> Running pre-commit hook
>> Running tests
>> ............................
>> Failed!
>> Tests must pass before commit!

Cheating

If we really have to skip tests we can use --no-verify flag like this:

# pre-commit hook is skipped
git commit --no-verify -m "test"

pre-commit or pre-push?

Until now we were working with pre-commit hook. My advice is to stick with it, and change to pre-push when tests starts to take too much time.

If you are reading carefully, you noticed that our tests are run against current state of files in the repository, not only commited ones. It's because a failproof script stashing not commited changes and restoring them after running is not trivial and out of scope of this post. You can do it on your own, but most of the time it's not an issue.

That's it. Everything described here can be found in this repository. I'm using that approach in almost every project and it helps me to keep good code quality. Thanks for reading!