Skip to content
Lucas Caton

How to automatically run automated tests and code analyzers via Git hooks

Run CI tools locally for a smoother workflow

Lucas Caton

Lucas Caton

I love being able to run the same tools locally that I'd use on a CI server to catch issues early. There are various tools you might want to run:
  • Automated tests
  • Code analyzers, such as linters and security vulnerability scanners
  • Anything else you'd run on your CI server
However, you may face the following issues:
  • 👎 Running all these tools can sometimes take too long
  • 👎 You might not want to run everything before pushing your changes to your code hosting platform, like GitHub
  • 👎 You may forget what to run, especially if you need to run many different tools
  • 👎 What and how you run these tools might differ from how a colleague working on the same project would

What I tried before (and didn't work as well as I expected)

In 2015 (9 years ago!), I created a Ruby gem called massa to help solve this problem. I'm no longer using or maintaining this project, but the idea was to create a YAML file where you could specify what you'd like to run and even define which tools are required. This way, optional tools wouldn't cause the process to fail if they encountered issues/offenses.
Eventually, I stopped using that and began relying on my ZSH history, ZSH autosuggestions, and fzf to remind me of everything I need to run. The only reason this isn't a (very) bad idea is that I was running everything in one single command. Here's an example for a Rails app:
bundle exec rubocop && bundle exec rspec && bundle exec brakeman --run-all-checks --exit-on-warn --exit-on-error --rails7 --no-pager
As you can probably tell, commands can get lost in the history, and this solution doesn't address some of the issues described at the beginning of this post.

New approach

I'm now trying something that seems to be a better overall approach: Git hooks. I've been using Git hooks for ages but never spent time creating a formal process across the open-source projects I maintain and my side projects.
The first thing I did was to create a default git pre-commit hook. With that and the [init] templatedir setting in my ~/.gitconfig (you can see that in the linked commit too), every time I clone a repo or create a new one with git init, the new repos will automatically get a copy of that hook saved in .git/hooks/pre-commit.
You can also manually create these files and give them execution permission by running chmod +x .git/hooks/pre-commit.
This is what my pre-commit hook script looks like at the moment:
#! /bin/bash
set -euxo pipefail

# >>> For Node.js/React/React Native projects:
# yarn lint
# yarn test
# yarn test:all

# >>> For Ruby/Rails projects:
# bundle exec rubocop
# bundle exec rspec
# bundle exec brakeman --run-all-checks --exit-on-warn --exit-on-error --rails7 --no-pager --quiet
# bundle exec rails_best_practices . -c config/rails_best_practices.yml

# TODO: uncomment the above lines and remove the below line
echo "⚠️ Edit or remove the .git/hooks/pre-commit file"
For each project, I uncomment the lines that I want to run and remove the echo with the warning at the end of the script. When necessary, I add extra project-specific tools as well.
Now, every time you attempt to commit something, that script will run. If it fails, the commit will not be completed, and you'll see what's failing. I also like to keep the x flag in the set command to see what command is running. If all tools pass, i.e., if all commands finish their execution with status 0, the commit will be completed.

What if it takes too long?

In these cases, I like to have two hooks:
  • pre-commit: runs quick things, usually linters and fast automated tests
  • pre-push: runs everything, only when running git push
You can always use the --no-verify flag to skip Git hooks. For example: git commit --no-verify -m "commit message"

Aliases, because I'm lazy

I'm an alias addicted. For different reasons, I may want to run the pre-commit hook before I'm ready to actually commit the changes, so I've set the following aliases
alias ch=".git/hooks/pre-commit" # ch = (pre-)Commit Hook
alias ph=".git/hooks/pre-push" # ph = (pre-)Push Hook
Let me know in the comments below how you solve this problem and if you have a different/better solution 🙂