in Programming

Git Hooks: Check Code Quality & Unit Tests on Every Commit

Automatically lint your code, check unit tests, and run static analysis every time you run “git commit”. It’s a developer’s local CI pipeline.

Let me know if this sounds familiar. Your project is following a coding standard but commits keep making it into master that don’t follow the coding standard. Or during a code review, you realize the author of the merge request hasn’t run it through a linter yet or that the unit tests are now failing. Instead of you having to be “that guy who asks everybody to clean up their code or fix the unit tests”, you could automate these code quality checks. Plus think of the time saved in the back and forth in the code review. The code review no-longer needs to be about code style or if the tests pass, and can instead focus on what matters; is the implementation sound, is there a better way it could be done, and is it properly tested?

You might say “well my IDE catches this type of stuff”, and it’s a common practice to run your linter and unit tests in your IDE. However, for IDE tooling, most code quality tools are up to the developer to set up or not. Plus, sometimes developers miss the warnings in their IDE’s or forget to run the tests before pushing up their merge request. That’s why having automatic code quality checks in the continuous integration (CI) pipeline is a great final catch before the code makes it into master. And don’t get me wrong, I think you should have these checks there as well, but if you wait for the CI pipeline to catch these before you fix them, it’s a much slower process than checking them locally. Especially if you have multiple issues, waiting for the CI pipeline multiple times can slow down your development process. Continuous local validation is quicker than remote.

Plus, wouldn’t it be better if this was caught before the pull request was created? Better yet, before the commit was created? Avoiding messy one-off fix commits or having to squash them constantly? This is where a pre-commit hook can come in handy. Let’s look at three great options to leverage git hooks as a solution.

Side note: If you’re curious why you should follow a coding standard, and how to set up a linter for PHP on the command line or in your IDE, checkout my previous article: Is Your Code Up To Sniff?

Git Pre-commit Hooks

Git provides hooks that allow you to run a script before commiting, before pushing, after merging, etc. If the script doesn’t report a success, it stops you from completing that git action until the script reports a successful execution. The git hook we’re specifically interested in is the pre-commit hook. A pre-commit hook runs when you type “git commit” but won’t bring up the prompt for entering a git commit message if the hook script doesn’t report a success first.

We’ll look at 3 popular tools to achieve this; GrumPHP, Husky (JS), and pre-commit.com (python). Husky is definitely the most popular of the three, but pre-commit.com has been gaining ground with support for multiple languages, and I feel like GrumPHP is really starting to catch on in the PHP community. You might think “why would I need a tool? Just call the lint command from the pre-commit hook” but the 3 biggest reasons to use these tools are:

  1. Auto-install

How do you get your team to install the pre-commit hook? Do you ask each of them to manually install it, and to follow instructions on how to do it in your readme? Here’s the clever part: these tools can install the pre-commit hook automatically when developers use Composer or NPM; something they’d have to do to run your codebase anyway.

  1. Only Lint Changed Files

A key feature to these tools is that you only want to lint the files which have changed (not the entire project), so that the pre-commit hook can run as fast as possible and not get in the way of getting your commit done. Which is important because running a linter across your entire codebase can take a minute or two if you’re working on big codebases, and having to wait that long each time you’re trying to get a commit created can drive people crazy. 

  1. Task Parallelization

If you’re doing multiple tasks (eg. linting and running unit tests) in your pre-commit hook, but these tools can run all the tasks in parallel so they complete much faster.

GrumPHP

GrumPHP is “a PHP code-quality tool” with many features, one of which includes registering a pre-commit to use PHP_CodeSniffer or PHP CS Fixer to lint your code. Though there’s much more it can do on every commit, such as running a PHPUnit (or Pest) test suite, linting the commit message format, normalizing the composer.json file, or running PHPStan or Psalm for static code analysis. You can find a full-list of tasks GrumPHP can perform here.

Some popular open source packages utilizing GrumPHP are fruitcake/laravel-cors and league/omnipay to name a few.

Basic Config Example

Starting off, a great basic config would be to check for syntax errors and code styling. We’ll use `phplint` to check for syntax errors and `phpcs` to run PHP_CodeSniffer for linting code style.

// grumphp.yml
grumphp:
    tasks:
        { phplint: null, phpcs: null }

Advanced Config Example

While the previous example was a great starting point, you’ll most likely want to take advantage of some of the more advanced features:

  • Turn on auto-fixers (no need to run fix commands, GrumPHP can fix the issues for you!)
  • Add PHPUnit (or Pest) for unit testing.
  • Add PHPStan for static code analysis.
  • Add ESLint if your project uses frontend javascript eg. React, Vue, etc
  • Optional: Turn off ASCII output (GrumPHP uses fun ASCII art in its pass/fail output, but as cool as the ASCII output is, maybe you need to be a little bit more “profesh” if using this in a work environment).
grumphp: {
    ascii: null,
    fixer: { enabled: true, fix_by_default: true },
    tasks: {
        phplint: null,
        phpcs: null,
        phpstan: null,
        phpunit: null,
        eslint: null
    }
}

Notes for Speed

PHPUnit and static code analysis can be slower tasks. You may wish to tweak or drop these types of pre-commit tasks if you find they’re slowing down your commit workflow too much.

PHPUnit

PHPUnit can be slow because it runs all your tests. Ideally, it would only check the test related to the code that changed, but it doesn’t know what other files and tests might be affected by the changed files, so it has to run them all. If you find your commits taking too long, alternatively, you could run only a subset of your unit tests by breaking them up into multiple test suites to make it complete at a satisfactory speed (maybe create a test suite that just includes critical or faster tests). 

Aside: It would be awesome if PHPUnit had the ability to run all tests for a given class (open source project opportunity here)! You could probably write a script to look for tests in two ways;

  1. Run any test at “/tests/Unit/{same path as class}Test.php”. This assumes you follow the same folder and naming structure in /tests/Unit/ as you do in /app or /src, but I think that’s relatively common.
  2. Run any test with @covers annotations for this class

Static Code Analysis

Unlike PHPUnit for Static Code Analysis it only runs on the changed files, but the process is just generally slower (it’s compute heavy, though they can leverage cache so multiple runs are quicker). I’ve opted for just PHPStan and not Psalm as well, as I found Psalm slower on my Laravel projects.

Alternative to GrumPHP: CaptainHook

CaptainHook is another popular git hook management tool in the PHP community. CaptainHook explains how it compares to GrumPHP on their site. If you plan on having a lot of custom tasks, this might be worth exploring as it’s a little more about managing cli commands, kind of like Husky for Javascript which we’ll talk about next actually. Captain Hook also has support for other git hooks as well (not just pre-commit0, while GrumPHP focuses on pre-commit hooks. A popular package utilizing CaptainHook is ramsey/uuid.

Husky + lint-staged

Is your project partially or completely written in Javascript? Husky might be the tool for you instead of GrumPHP! Husky can manage git hooks to run both JS linters and unit testing, though it can do your PHP or other language pre-commit tasks too. What really brings the power to Husky is lint-staged. lint-staged adds to Husky that concept of only running your hooks against the files that have changed.

Here’s some popular projects using Husky + lint-staged: Create React App, Angular, Electron, Babel, and Webpack. There’s even php community members using it, as an example check out Sebastian De Deyne’s blog post on using Husky for a PHP project.

Unique features: 

  • lint-staged supports partially staged files so it will only look at the changed lines that are staged (not the whole file that’s been staged).
  • If the fixer didn’t cause an error, any changes will be auto-added before commit (no need to make `git add` one of the subtasks). It will rollback any changes if it runs into an error.
  • By default a backup stash will be created before running the tasks, and all task modifications will be reverted if any one of your pre-commit tasks reports an error. You can use the –no-stash option to disable creating the stash, and instead leave all modifications in the index when it aborts the commit.

Jest is great as a pre-commit hook:

As far as unit tests go, Jest provides a very cool argument –findRelatedTests which informs Jest to run only those test cases which get impacted by the changed files. This can make it significantly faster than other unit testing frameworks like PHPUnit mentioned above, because those ones have to run the whole test suite.

Basic Config Example

Husky setup:

// .husky/pre-commit
 #!/bin/sh
. "$(dirname "$0")/_/husky.sh"
lint-staged

lint-staged setup:

// package.json
{
   "lint-staged": {
     "**/*.js": [
       "eslint --fix",
       “jest --bail --findRelatedTests"
     ]
   }
}

Using Husky for PHP Tasks

As noted earlier, Husky can be used to run php pre-commit tasks too. This is because anything you can run on the command line, husky can run as a pre-commit task. Here’s an example of what a Husky setup might look like for running a bunch of php tasks equivalent to when we setup GrumPHP:

// .husky/pre-commit - Add phpunit
 #!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run phpunit
lint-staged
// package.json - Run PHP Lint, PHP CS Fixer, PHPStan, and ESLint
{
  "lint-staged": {
    "*.php": [
      "./vendor/bin/parallel-lint",
      "./vendor/bin/php-cs-fixer fix",
      "./vendor/bin/phpstan analyse"
    ],
    "resources/js/*.{js,jsx,ts,tsx}": "eslint --fix"
  }
}

PHPUnit and Other Long Running Tasks

Notice we’re running phpunit as a separate pre-commit task. This is because lint-staged passes all changed files as params to it’s tasks, this would pass the list of changed files to phpunit (not a list of test files), so we want to run PHPUnit on all tests since we can’t tell which ones have been affected. 

Pre-Push Hooks

If you commit often and squash (I’m looking at you “wip wip squash” devs) you may prefer to move PHPUnit to a pre-push commit hook instead. This is actually handy and not something GrumPHP can do because GrumPHP doesn’t have support for pre-push git hooks. 

PHP_CodeSniffer

PHP_CodeSniffer doesn’t work well with Husky. This is because the code fixer `phpcbf` returns an exit code of 1 after fixing but Husky expects an exit code of 0 for all successful tasks (PHP_CodeSniffer will fix this in v4). In the meantime, php-cs-fixer is a better option, I prefer it over PHP_CodeSniffer as it has more linter rules, although they don’t have PSR12 support yet (PSR2 is fine for now).

Pre-commit.com

If your company has codebases in languages other than Javascript and PHP, Pre-commit.com might be a good alternative to GrumPHP or Husky. Pre-commit.com aims to be a language agnostic solution for pre-commit hooks. I originally disregarded Pre-commit.com as an option because it’s written in Python and I don’t have codebases in Python, but it doesn’t require python (or pip) to install it as it can be easily installed via brew “brew install pre-commit“, and it has git hooks for projects in way more languages than Python (20+ languages actually).

Another neat concept with Pre-commit.com is you install it globally one time on your computer and then any project you git clone which has a .pre-commit-config.yaml in it, it can automatically run pre-commit hooks on if you enable this feature. Click here to see how to automatically enable pre-commit on repositories so that any time you git clone a repo with a .pre-commit-config.yaml file in it, it will automatically install and run those pre-commit hooks on.

Php isn’t listed under the languages supported but there are hooks for php maintained by 3rd party contributors. The main ones all still work, and I haven’t run into an issue with them, but just a heads up they are not maintained and a successor fork hasn’t really taken over in popularity so far.

Here’s an example pre-commit.com hook setup for php and javascript tasks similar to the examples for GrumPHP and Husky above: 

// .pre-commit-config.yaml
minimum_pre_commit_version: 2.18.0
repos:
 - repo: https://github.com/digitalpulp/pre-commit-php
   rev: 1.4.0
   hooks:
     - id: php-lint
     - id: php-cs
     - id: php-unit
     - id: php-stan
 - repo: github.com/pre-commit/mirrors-eslint
   rev: 8.25.0
   hooks:
     - id: eslint

Advanced Example

If you’re a fan of Conventional Commits we can have commit messages linted as well:

// .pre-commit-config.yaml
minimum_pre_commit_version: 2.18.0
default_stages:
 - commit
 - push
default_install_hook_types:
 - pre-commit
 - commit-msg
repos:
 - repo: https://github.com/digitalpulp/pre-commit-php
   rev: 1.4.0
   hooks:
     - id: php-lint
     - id: php-cs
     - id: php-unit
     - id: php-stan
 - repo: github.com/pre-commit/mirrors-eslint
   rev: 8.25.0
   hooks:
     - id: eslint
 - repo: https://github.com/compilerla/conventional-pre-commit
   rev: v1.2.0
   hooks:
     - id: conventional-pre-commit
       stages: [ commit-msg ]

Conclusion

So which one would I use, GrumPHP, Husky or Pre-commit.com? If your project is primarily PHP, use GrumPHP. If your project is primarily javascript, use Husky. If your company has codebases in 3 or more languages (or any of them are python) it might be handy to standardize on pre-commit.com. I do have to say though, as the newer kid in this space, I really love GrumPHP’s implementation. GrumPHP has a ton of common tasks built-in, there’s a good chance if you have a common setup, you don’t have to pass any custom params or manually script up anything. GrumPHP makes the config file very simple and easy to read. Husky leaves it up to the developer to discover or think up pre-commit tasks to use, but that also makes it more versatile, especially if you have unique pre-commit tasks you want to run. Whichever one you choose, just try it out, I’m confident you’ll get hooked.