Write your git hooks in PHP and keep them under git control

Last month, in the PHP Barcelona Monthly Talk, I was talking with some mates about the GitHub migration we have recently done at Atrápalo. They were interested in branches, deployments, code reviews and so on. However, they were specially surprised about who we are dealing with pre-commit hooks at Atrápalo. Let’s see if there’s more people out there interested in the subject.

Checks in our pre-commit hook

At Atrápalo, each time a developer wants to commit its code, we run several checks:

  1. Syntax check with php lint (“php -l”): We check every committed file has a valid PHP syntax.
  2. Sync check of composer.json and composer.lock files: We check these two files are committed together in order to avoid committing the json but not the lock and generate some issue to another developers.
  3. PHP CS Fixer check: With the –dry-run parameter it does not fix, just say what the problems are. With the –fixers parameter you can control what fixers you want to execute.
  4. PHP Code Sniffer check: Same as before, but another rule that checks another rules.
  5. PHPMD: We have enabled the controversial rules.
  6. Unit Testing check: We run around 3.000 tests right now.

Maybe you think it’s too much, and it is, however it takes about seconds (specially if your tests are fast) and it mainly guarantees that you don’t break the unit tests and the code is formatted based in your coding standard. Check Coding StandardTDD and Continuous Integration as XP related practices.

Keeping our hooks under git control

In any Git project, you have a .git folder. Inside, there is another hooks folder where hooks scripts samples can be found. For more info, visit git hooks reference. For making a hook work, just remove the .sample extension from a file and done.

Screen Shot 2014-07-11 at 19.13.16

At Atrápalo, we have a doc/hooks folder with two files:

commit-msg: Manages commit msg hook (for issue tracking purposes)
pre-commit: Manages pre-commit hook

Both are plain files, so managed without any problem with git.

Screen Shot 2014-07-11 at 19.24.08

So, what’s the silliest idea to link these two folders? Yep! a soft link!

Screen Shot 2014-07-11 at 19.24.17

When a developer clones the project, it just needs to:

rm -rf .git/hooks
ln -s ../docs/hooks .git/hooks

Remembering to set up the hooks

If you want to remember you developers to do the trick, add a Composer script to remember it.

... composer.json
    "scripts": {
        "pre-update-cmd": "AtrapaloLib\\Composer\\Script\\Hooks::checkHooks",
        "pre-install-cmd": "AtrapaloLib\\Composer\\Script\\Hooks::checkHooks"
    }
<?php

namespace AtrapaloLib\Composer\Script;

use Composer\Script\Event;

class Hooks
{
    public static function checkHooks(Event $event)
    {
        $io = $event->getIO();
        $gitHook = @file_get_contents(__DIR__.'/../../../../.git/hooks/pre-commit');
        $docHook = @file_get_contents(__DIR__.'/../../../../docs/hooks/pre-commit');

        $result = true;
        if ($gitHook !== $docHook) {
            $io->write('<error>You, motherfucker, please, set up your hooks!</error>');
            $result = false;
        }

        return $result;
    }
}

You could make the script create the hooks, however, I’m trying first to be polite about it. For more information about how to create scripts in Composer visit its reference.

pre-commit hook in PHP

An easy way to develop your hooks is in PHP, you have set up everything, so carry on with PHP. Following there is an example. The most interesting part is that we are using Symfony Console Component as the Application, each library comes from composers (phpunit, phpmd, php-cs-fixer, etc.) and we are running all the command using the Symfony Process Component.

#!/usr/bin/php
<?php

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

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

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

    const PHP_FILES_IN_SRC = '/^src\/(.*)(\.php)$/';
    const PHP_FILES_IN_CLASSES = '/^classes\/(.*)(\.php)$/';

    public function __construct()
    {
        parent::__construct('Code Quality Tool', '1.0.0');
    }

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

        $output->writeln('<fg=white;options=bold;bg=red>Atrapalo Code Quality Tool</fg=white;options=bold;bg=red>');
        $output->writeln('<info>Fetching files</info>');
        $files = $this->extractCommitedFiles();

        $output->writeln('<info>Check composer</info>');
        $this->checkComposer($files);

        $output->writeln('<info>Running PHPLint</info>');
        if (!$this->phpLint($files)) {
            throw new Exception('There are some PHP syntax errors!');
        }

        $output->writeln('<info>Checking code style</info>');
        if (!$this->codeStyle($files)) {
            throw new Exception(sprintf('There are coding standards violations!'));
        }

        $output->writeln('<info>Checking code style with PHPCS</info>');
        if (!$this->codeStylePsr($files)) {
            throw new Exception(sprintf('There are PHPCS coding standards violations!'));
        }

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

        $output->writeln('<info>Running unit tests</info>');
        if (!$this->unitTests()) {
            throw new Exception('Fix the fucking unit tests!');
        }

        $output->writeln('<info>Good job dude!</info>');
    }

    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) {
            throw new Exception('composer.lock must be commited if composer.json is modified!');
        }
    }

    private function extractCommitedFiles()
    {
        $output = array();
        $rc = 0;

        exec('git rev-parse --verify HEAD 2> /dev/null', $output, $rc);

        $against = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
        if ($rc == 0) {
            $against = 'HEAD';
        }

        exec("git diff-index --cached --name-status $against | egrep '^(A|M)' | awk '{print $2;}'", $output);

        return $output;
    }

    private function phpLint($files)
    {
        $needle = '/(\.php)|(\.inc)$/';
        $succeed = true;

        foreach ($files as $file) {
            if (!preg_match($needle, $file)) {
                continue;
            }

            $processBuilder = new ProcessBuilder(array('php', '-l', $file));
            $process = $processBuilder->getProcess();
            $process->run();

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

                if ($succeed) {
                    $succeed = false;
                }
            }
        }

        return $succeed;
    }

    private function phPmd($files)
    {
        $needle = self::PHP_FILES_IN_SRC;
        $succeed = true;
        $rootPath = realpath(__DIR__ . '/../../');

        foreach ($files as $file) {
            if (!preg_match($needle, $file) || preg_match('/src\/AtrapaloLib\/ORM\/Doctrine\/DBAL\/Driver\/Adodb/', $file)) {
                continue;
            }

            $processBuilder = new ProcessBuilder(['php', 'bin/phpmd', $file, 'text', 'controversial']);
            $processBuilder->setWorkingDirectory($rootPath);
            $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())));
                if ($succeed) {
                    $succeed = false;
                }
            }
        }

        return $succeed;
    }

    private function unitTests()
    {
        $processBuilder = new ProcessBuilder(array('php', 'bin/phpunit'));
        $processBuilder->setWorkingDirectory(__DIR__ . '/../..');
        $processBuilder->setTimeout(3600);
        $phpunit = $processBuilder->getProcess();

        $phpunit->run(function ($type, $buffer) {
            $this->output->write($buffer);
        });

        return $phpunit->isSuccessful();
    }

    private function codeStyle(array $files)
    {
        $succeed = true;

        foreach ($files as $file) {
            $classesFile = preg_match(self::PHP_FILES_IN_CLASSES, $file);
            $srcFile = preg_match(self::PHP_FILES_IN_SRC, $file);

            if (!$classesFile && !$srcFile) {
                continue;
            }

            $fixers = '-psr0';
            if ($classesFile) {
                $fixers = 'eof_ending,indentation,linefeed,lowercase_keywords,trailing_spaces,short_tag,php_closing_tag,extra_empty_lines,elseif,function_declaration';
            }
            $processBuilder = new ProcessBuilder(array('php', 'bin/php-cs-fixer', '--dry-run', '--verbose', 'fix', $file, '--fixers='.$fixers));

            $processBuilder->setWorkingDirectory(__DIR__ . '/../../');
            $phpCsFixer = $processBuilder->getProcess();
            $phpCsFixer->run();

            if (!$phpCsFixer->isSuccessful()) {
                $this->output->writeln(sprintf('<error>%s</error>', trim($phpCsFixer->getOutput())));

                if ($succeed) {
                    $succeed = false;
                }
            }
        }

        return $succeed;
    }

    private function codeStylePsr(array $files)
    {
        $succeed = true;
        $needle = self::PHP_FILES_IN_SRC;

        foreach ($files as $file) {
            if (!preg_match($needle, $file)) {
                continue;
            }

            $processBuilder = new ProcessBuilder(array('php', 'bin/phpcs', '--standard=PSR2', $file));
            $processBuilder->setWorkingDirectory(__DIR__ . '/../../');
            $phpCsFixer = $processBuilder->getProcess();
            $phpCsFixer->run();

            if (!$phpCsFixer->isSuccessful()) {
                $this->output->writeln(sprintf('<error>%s</error>', trim($phpCsFixer->getOutput())));

                if ($succeed) {
                    $succeed = false;
                }
            }
        }

        return $succeed;
    }
}

$console = new CodeQualityTool();
$console->run();

So that’s it! That was easy and useful. Hope it helps someone out there. If there’s people doing something different or better, please share to improve it.

Update:

If you want to directly set up your hooks when executing “composer install” or “composer update”, you can just add the bash lines to do so in the “scripts” section of your composer.json file. See an example:

    "scripts": {
        "pre-install-cmd": ["rm -rf .git/hooks", "ln -s ../docs/hooks .git/hooks"],
        "post-install-cmd": ["rm -rf .git/hooks", "ln -s ../docs/hooks .git/hooks"]
    }