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"]
    }
  • pretty awesome the combination between Symfony Console and hooks! thanks for sharing!

    • Carlos Buenosvinos

      Thanks for taking a look!

  • Thanks for publishing this article. Great content as always!

    I’m curious about how can you execute all of this “in a few seconds”. I’m particularly interested in knowing how can you execute more than 3,000 tests in just a few seconds. Could you please share some of the techniques that you apply to those ultra-fast tests? Thanks!

    • Carlos Buenosvinos

      QA tools run quite fast because on each commit, there are not so many files. PHP CS Fixer runs quite fast. For the unit testing, “mybuilder/phpunit-accelerator” + mocking resources without abusing mockery + nice machine (SSD mainly).

      • Thanks for the tips! I’ll check out mybuilder/phpunit-accelerator.

        • Carlos Buenosvinos

          Not using mockery when not necessary can increase up to ten times performance of a unit test

  • Carlos, thanks for good tools!

    One question: are your script correctly running when used with help symlink. Particularly how this code correctly works:

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

    because __DIR__ contains path to real file pre-commit, not symlnk?

  • That’s a great job! very nice idea! Although, I broke up with PHP long ago and I’ll appreciate if you can bring any similar example with node.js

    Nice article by the way! :)

  • great article, so maybe doc/hooks maybe is not the appropriate directory, what do you think about use app/script/git/hooks folder instead?

    • Carlos Buenosvinos

      No problem. Where you save your hooks is up to you. Remember to update folder references in your hooks.

  • is this code as a public github repo?

  • Daniel

    couldn’t find an official repo, so used the above example to make my own, hope that is ok?! if it helps any one, or if any one wants to contribute, please check out https://github.com/danielanteloagra/project-tools

  • Pingback: Buenas prácticas y consejos para desarrollar en PHP (Recopilatorio) | Jesús L.C.()

  • An

    Thank you for the article!

    Just want to tell you, i public github repo. If you have any questions or feedback, please let me know! https://github.com/hoangthienan/symfony-pre-commit

  • Thanks for such a great post, I was trying to use this within a project, but then I decided to published a PHPQA Analyzer CLI tool based on this blog post to reuse globally, source at:
    https://github.com/jmolivas/phpqa

  • Pingback: PHP – Estilo de código, estándar PSR 2 | Codely.TV()

  • Jessica Mauerhan

    What is the hash string you’re comparing the commit against?

    • Carlos Buenosvinos

      It’s the git empty tree, the result of executing “git hash-object -t tree /dev/null”

  • Jessica Mauerhan

    Hi Carlos, I used this post sometime back in 2015 to set up managed git hooks, thanks so much for sharing it! I was just describing it to a colleague as we were trying to think of a way to automatically run composer install when someone does a git pull and the composer.json/lock has changed – however, if the install of the hooks is part of composer, that doesn’t help us much, because you have to manually run composer and that’s the missing step here :) Any ideas? Thanks!

    • Could you elaborate a bit more? ;)

    • Pablo Rodriguez Valenzuela

      Hi Jessica, you can take a look to this gist

      https://gist.github.com/prodriguezval/e2dfe94300f24191b3ea3dea21e9854a

      I think that maybe is what you’re looking for :)

      Greetings

      • Jessica Mauerhan

        Perfect! Thanks :)

        • Jessica Mauerhan

          Well wait – that doesn’t handle installing the hooks.

          • Hi Jessica, sorry for the late reply, a bit busy month. I have updated the post to answer your question. Thanks!

          • cbuenosvinos

            Hi Jessica, take a look to the update at the end of the post ;)

  • Bruno Leite

    I work on a company that uses Windows. I know I know… LOL

    So… what is the equivalent command to run on Windows to this line right here:

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

    Windows doesn’t have the commands “egrep” and “awk”.