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?
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!