Automatic Quality Assurance with Git Hooks
For some months now I’ve been trying to make sure every commit in my Rails project passes all tests and style checks. This helps to guard against code quality taking a nose-dive that usually is inevitable. Git (and Mercurial) allows us to run scripts at selected points of our version-control workflow.
At a high level, this means we can write a script that makes a number of checks against our codebase, and have Git automatically run this script any time we try making a commit.
I have a bash script in my Rails app that does the following:
- Stash unstaged changes so checks are only run against staged changes
- Run style checks with Rubocop
- Check for security vulnerabilities with Brakeman
- Run RSpec tests
- Run Cucumber tests
- Update application version number
If at any point any of those checks should fail, we should pop the stash and abort the commit.
Stashing Unstaged Changes
In my script I check if there are any staged changes, and stash anything that
isn’t staged. This is because I only want to run checks against changes I’m
including in the commit. If there are no changes staged for the commit, the
script exits early without stashing anything. Don’t worry about the highlight
function for now; it’s just a wrapped version of printf
with some colour, and
you’ll see it later.
if git diff --quiet --cached HEAD; then
highlight "No changes to test; exiting"
exit 0
fi
if [ "$(git status -s | wc -l)" != 0 ]; then
highlight "Stashing unstaged changes"
fi
git stash save --keep-index --include-untracked
Quality Control
For a git-hook to succeed, the script should exit with an error code of 0
.
After each check I add the exit code of that check to a counter, and exit the
commit hook with that counter as the error code. If none of the checks fail, the
exit code will be 0
and Git will allow the commit. Otherwise, the commit is
aborted, and I have to fix the mistakes in my codebase.
The call to trap
is a bit like Kernel#at_exit
that you find in Ruby; it
listens for an EXIT
event and runs some function before exiting. In my case
I’ve asked it to run a function called pop_stash
, which will revert my working
directory to the state it was in before stashing unstaged changes.
My checks are specific to Ruby/Rails projects, but there are most likely equivalents for whichever tech stack you’re using. I’m using the brakeman gem to protect me from creating obvious security vulnerabilities, and I think every project ought to have something like this running regularly.
declare -i ERRORS=0
trap pop_stash EXIT
highlight "Checking for style/syntax errors"
rubocop --rails --fail-fast
ERRORS+=$?
highlight "Checking for security vulnerabilities"
brakeman -q -z
ERRORS+=$?
highlight "Running RSpec tests"
rspec --fail-fast
ERRORS+=$?
highlight "Running Cucumber tests"
cucumber --format progress
ERRORS+=$?
exit $ERRORS
Automatic Application Versioning
I like the idea of seeing my application version printed somewhere in my app. If all of my quality control checks pass, I write a datestamp to a file in my application. I went with a datestamp instead of major/minor numbers or a commit hash because it’s the simplest thing that works, and also it makes the most sense. I use Git to then add the bumped version to the staging area.
date +%Y%m%d%H%M > config/version
git add config/version
If you want to use this version number in your Rails app, you can add the
following in config/application.rb
.
module MyApp
class Application < Rails::Application
config.version = File.read("config/version")
end
end
The Whole Script
I was jumping around the script before so I could explain its components in
finer detail, but this is an imperative script so the order in which each line
is executed is important. Here’s the complete script for you to copy and
paste reference.
#!/bin/bash
set -e
highlight() {
tput setaf 6
printf "%s\n" "$1"
tput sgr0
}
pop_stash() {
highlight "Reapplying unstaged changes"
git stash pop
}
declare -i ERRORS=0
if git diff --quiet --cached HEAD; then
highlight "No changes to test; exiting"
exit 0
fi
if [ "$(git status -s | wc -l)" != 0 ]; then
highlight "Stashing unstaged changes"
fi
git stash save --keep-index --include-untracked
trap pop_stash EXIT
highlight "Checking for style/syntax errors"
rubocop --rails --fail-fast
ERRORS+=$?
highlight "Checking for security vulnerabilities"
brakeman -q -z
ERRORS+=$?
highlight "Running RSpec tests"
rspec --fail-fast
ERRORS+=$?
highlight "Running Cucumber tests"
cucumber --format progress
ERRORS+=$?
date +%Y%m%d%H%M > config/version
git add config/version
exit $ERRORS