Code Quality Enforcement via Git Pre-Commit Hook

There are a lot of methods for measuring the quality of a codebase, but if you’re not running these tools locally, it’s much harder to make headway on improvements. I always suggest running quality checks before pushing changes, so I wrote a git hook to enforce the quality checks before commit. A git hook is a script that runs when a specific git action is taken, and you can read more about the various types of hooks.

I chose to set this up as a pre-commit hook rather than a pre-push hook to ensure the problems are fixed as quickly as possible, but if you are diligent about pushing often, you could use pre-push instead.

First, you’ll need to install the tools you want to use. I’m using PHP Mess Detector, and PHP CS Fixer (not a quality tool, but it’s also good to run style fixes locally to prevent conflicts).

> composer require --dev phpmd/phpmd
> composer require --dev fabpot/php-cs-fixer

We’ll also be using two Symfony components in our script, so go ahead and install those too.

> composer require --dev symfony/console
> composer require --dev symfony/process

Now we’ll create a PHP script that runs these tools against just the files being commited, and cancels the commit if the tools report problems. It also ensures the composer.lock file stays updated. This script needs to go in your project’s .git/hooks directory.

#!/usr/bin/php
<?php

require __DIR__ . '/../../vendor/autoload.php';

use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\ProcessBuilder;

class CodeQualityTool extends Application
{
    private $output;
    private $input;
    private $projectRoot;

    //The locations of the files you want to measure. Add/remove as needed.
    const PHP_FILES = '/^(.*)(\.php)$/';

    public function __construct()
    {
        /** OS agnostic */
        $this->projectRoot = realpath(__DIR__ . '/../../');
        parent::__construct('Code Quality Tool', '1.0.0');
    }

    /**
     * @param $file
     *
     * @return bool
     */
    private function shouldProcessFile($file)
    {
        return preg_match(self::PHP_FILES, $file);
    }

    public function doRun(InputInterface $input, OutputInterface $output)
    {
        $this->input  = $input;
        $this->output = $output;

        $output->writeln('<fg=white;options=bold;bg=cyan> -- Code Quality Pre-Commit Check -- </fg=white;options=bold;bg=cyan>');
        $output->writeln('<info>Fetching files</info>');
        $files = $this->extractCommitedFiles();

        $output->writeln('<info>Fixing code style</info>');
        $this->codeStyle($files);

        $output->writeln('<info>Checking composer</info>');
        if (!$this->checkComposer($files)) {
            throw new Exception('composer.lock must be commited if composer.json is modified!');
        }

        $output->writeln('<info>Checking for messy code with PHPMD</info>');
        if (!$this->checkPhpMd($files)) {
            throw new Exception(sprintf('There are PHPMD violations!'));
        }

        $output->writeln('<fg=white;options=bold;bg=green> -- Code Quality: Passed! -- </fg=white;options=bold;bg=green>');
    }

    /**
     * @return array
     */
    private function extractCommitedFiles()
    {
        $files  = [];
        $output = [];

        exec("git diff --cached --name-status --diff-filter=ACM", $output);

        foreach ($output as $line) {
            $action  = trim($line[0]);
            $files[] = trim(substr($line, 1));
        }

        return $files;
    }

    /**
     * @param $files
     *
     * This function ensures that when the composer.json file is edited
     * the composer.lock is also updated and commited
     *
     * @throws \Exception
     */
    private function checkComposer($files)
    {
        $composerJsonDetected = false;
        $composerLockDetected = false;

        foreach ($files as $file) {
            if ($file === 'composer.json') {
                $composerJsonDetected = true;
            }

            if ($file === 'composer.lock') {
                $composerLockDetected = true;
            }
        }

        if ($composerJsonDetected && !$composerLockDetected) {
            return false;
        }
        return true;
    }

    /**
     * @param array $files
     *
     * @return bool
     */
    private function codeStyle(array $files)
    {
        $commandLineArgs = [
            'bin' . DIRECTORY_SEPARATOR . 'php-cs-fixer',
            'fix',
            null,
            '--level=psr2'
        ];

        foreach ($files as $file) {
            if (!$this->shouldProcessFile($file)) {
                continue;
            }

            $commandLineArgs[2] = $file;
            $processBuilder     = new ProcessBuilder($commandLineArgs);
            $processBuilder->setWorkingDirectory($this->projectRoot);
            $phpCsFixer = $processBuilder->getProcess();
            $phpCsFixer->run();

            exec('git add ' . $file);
        }
    }

    /**
     * @param $files
     *
     * @return bool
     */
    private function checkPhpMd($files)
    {
        $succeed = true;

        foreach ($files as $file) {
            if (!$this->shouldProcessFile($file)) {
                continue;
            }
            $processBuilder = new ProcessBuilder([
                'bin/phpmd',
                $file,
                'text',
                'phpmd-rules.xml'
            ]);
            $processBuilder->setWorkingDirectory($this->projectRoot);
            $process = $processBuilder->getProcess();
            $process->run();

            if (!$process->isSuccessful()) {
                $this->output->writeln($file);
                $this->output->writeln(sprintf('<error>%s</error>', trim($process->getErrorOutput())));
                $this->output->writeln(sprintf('<info>%s</info>', trim($process->getOutput())));
                $succeed = false;
            }
        }

        return $succeed;
    }
}

$console = new CodeQualityTool();
$console->run();</pre>

You’ll probably want to also commit the hook into the repository so you can share it between everyone who works on the project. An easy way to do this is to create a hooks directory in your project, copy the file in there, and then add some code in your composer.json that copies the files into the user’s .git/hooks folder after install. This will ensure that anyone who starts working on your project gets the hooks when they run composer. All you have to do is add the copy command into the scripts property of composer.json:

"scripts": {
    "post-install-cmd": [
        "cp hooks .git/hooks"
    ]
}

Please feel free to comment if you have any questions, or share how you’ve enforced use of code quality tools in your project!

Leave a comment