Using Precommit Hooks For Static Code Analysis

In our last several articles, we’ve discussed how to use the PHP_CodeSniffer library to verify our code is following an agreed-upon coding standard and how to run the phpcs extension in VSCode to see where we’re not following the code as we type. We’ll eventually discuss how to move these checks to a Continuous Integration server but wouldn’t it be great if we could make sure our code was passing these checks before we push them anywhere?

In this article we’ll discuss how to use Git’s pre-commit hooks to run our static code analysis tools on just the set of files we’ve modified.

Exploring the .git Directory

When we run git init on a directory the command creates a “.git” directory inside of our current directory that git uses to maintain all of its data associated with our repository. We’re not going to go into most of the information here but some of the more important pieces are:

  1. The config file containing settings about our repository.
  2. The objects directory contains all of the diffs for our repository
  3. The hooks directory contains the hook scripts.
% ls -l .git
total 104
-rw-r--r--    1 scottkeck-warren  staff   3798 Apr 27 20:25 COMMIT_EDITMSG
-rw-r--r--    1 scottkeck-warren  staff    100 Mar  4 19:44 FETCH_HEAD
-rw-r--r--    1 scottkeck-warren  staff     23 Mar  4 19:44 HEAD
-rw-r--r--    1 scottkeck-warren  staff     41 Mar  4 19:44 ORIG_HEAD
-rw-r--r--    1 scottkeck-warren  staff    510 Mar  3 20:07 config
-rw-r--r--    1 scottkeck-warren  staff     73 Apr 27  2020 description
drwxr-xr-x   13 scottkeck-warren  staff    416 Apr 27  2020 hooks
-rw-r--r--    1 scottkeck-warren  staff  25072 Apr 27 20:25 index
drwxr-xr-x    3 scottkeck-warren  staff     96 Apr 27  2020 info
drwxr-xr-x    4 scottkeck-warren  staff    128 Apr 27  2020 logs
drwxr-xr-x  253 scottkeck-warren  staff   8096 Apr 27 20:23 objects
drwxr-xr-x    5 scottkeck-warren  staff    160 May 17  2020 refs

Git Hooks

The focus of this article is to discuss how to use the hooks directory to run our static code analysis tools. We can place scripts in this directory that will get run before certain actions occur in our repository. Like before we push (pre-push), after we update (post-update), and important for our discussion before we commit (pre-commit).

The hooks directory comes with sample scripts so we know what we can do.

% ls -l .git/hooks 
total 96
-rwxr-xr-x  1 scottkeck-warren  staff   478 Apr 27  2020 applypatch-msg.sample
-rwxr-xr-x  1 scottkeck-warren  staff   896 Apr 27  2020 commit-msg.sample
-rwxr-xr-x  1 scottkeck-warren  staff  3327 Apr 27  2020 fsmonitor-watchman.sample
-rwxr-xr-x  1 scottkeck-warren  staff   189 Apr 27  2020 post-update.sample
-rwxr-xr-x  1 scottkeck-warren  staff   424 Apr 27  2020 pre-applypatch.sample
-rwxr-xr-x  1 scottkeck-warren  staff  1638 Apr 27  2020 pre-commit.sample
-rwxr-xr-x  1 scottkeck-warren  staff  1348 Apr 27  2020 pre-push.sample
-rwxr-xr-x  1 scottkeck-warren  staff  4898 Apr 27  2020 pre-rebase.sample
-rwxr-xr-x  1 scottkeck-warren  staff   544 Apr 27  2020 pre-receive.sample
-rwxr-xr-x  1 scottkeck-warren  staff  1492 Apr 27  2020 prepare-commit-msg.sample
-rwxr-xr-x  1 scottkeck-warren  staff  3610 Apr 27  2020 update.sample

One of the more annoying parts about the Git Hooks is that while they’re in our git repository they’re not kept as part of the repository. That means that when we add the file to our repository it won’t automatically show up in the rest of the teams .git/hooks directory.

Our solution to get around that is to keep a copy of the pre-commit script outside the .git directory and then recommend people install it into their hooks directory. It’s not ideal but it does work. There are also third-party scripts that will help us maintain them (and we may discuss that in another article).

Our First Pre-Commit Script

To get started with our pre-commit script we’re going to make it as simple as possible and just run phpcs on both the app and tests directory of our sample Laravel project.

#!/usr/bin/env bash

./vendor/bin/phpcs --standard=PSR12 app tests

Now before we’re allowed to commit our code (pre-commit) our script will be run and we won’t be allowed to create the commit if our code is found in violation of the standards.

For example:

% touch test.md
% git add test.md 
% git commit -m "test"

FILE: /var/www/tests/CreatesApplication.php
----------------------------------------------------------------------
FOUND 2 ERRORS AFFECTING 1 LINE
----------------------------------------------------------------------
 16 | ERROR | [x] Expected at least 1 space before "."; 0 found
 16 | ERROR | [x] Expected at least 1 space after "."; 0 found
----------------------------------------------------------------------
PHPCBF CAN FIX THE 2 MARKED SNIFF VIOLATIONS AUTOMATICALLY
----------------------------------------------------------------------

Time: 247ms; Memory: 10MB

The amazing thing about this is because the script is part of the git repository most tools we use to interact with the repository will also run this script before creating the commit. For example, VSCode will display an error and ask us to refer to the log to figure out what to do to fix it.

** image here **

Now there are two downsides to the very basic script we created. The first is that the file we were getting errors on wasn’t even a file we were trying to commit. We didn’t cause the problem but it’s preventing us from committing our changes and requires us to spend time fixing someone else’s code which will make our pull request harder to review. The second is that even though we’re only making changes to one file it’s running the checks on all of our files. As we add tools to our pre-commit script and files to our project the amount of time it’s going to take to run all of those files will get unbearably long.

Our Second Pre-Commit Script

To help us with the problems we outlined above we’re going to turn to git-diff to help us find just the files that we’ve “staged” for the commit so we can run static analysis against them. To do this we’re going to use the following command.

git diff --diff-filter=AM --name-only --cached app tests | grep ".php$"

Let’s break this down so we can troubleshoot this.

  1. git diff -> Runs the git diff command which shows us changes in our repository
  2. --diff-filter=AM -> filters out files to only show us modifications and additions
  3. --name-only -> returns just the name of the file and not the contents
  4. --cached -> returns changes that have been staged for the next commit and not every changed file
  5. app tests -> limit our results to files in the app and tests directories
  6. | grep ".php$" -> Limit our results to just .php files

Now this list could be extremely lengthy and some tools only accept a single file at a time so instead of just pushing all the files to our static code analysis tools we’re going to pipe them through xargs so each one gets executed individually. Finally, we’re going to create a helper function to enable us to more easily add more static code analysis tools later.

#!/usr/bin/env bash

function __runStaticTool() #(name, command)
{
    echo -e "\n\n$1"
    output=$(eval "$2" 2>&1)
    exitcode=$?

    if [[ 0 == $exitcode || 130 == $exitcode ]]; then
        echo -e "Success"
    else
        echo -e "Failure\n\n$output"
        exit 1
    fi
}

modified="git diff --diff-filter=AM --name-only --cached app tests | grep \".php$\""
ignore="resources/lang,resources/views,bootstrap/helpers,database/migrations,bin"

__runStaticTool "PHP_CodeSniffer" "${modified} | xargs vendor/bin/phpcs --standard=PSR12 --ignore=${ignore}"

Now if we attempt to commit our code we’ll get much nicer results.

% git commit -m "test"


PHP_CodeSniffer
Success
[master 420753f] test
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100755 test2.md

In future articles, we’ll add more tools to this script to make it even more powerful.

What You Need To Know

  • Git allows us to define scripts to perform validation checks
  • pre-commit checks if a commit is valid
  • git diff allows us to find just the changed files we need