Adding Git Hooks Through Composer Dev-Dependencies

I have been working on refining my development workflow for some time now, in order to optimize the quality of my code. And as I am a big fan of automation, I tend to look for tools that just do their work in the background and only need my input when my mental processing power is truly needed. This leads to a greater focus on the coding and problem solving area of development.

One of the areas that was missing a satisfactory solution so far was the pre-commit checks that git was doing when new changes were committed to a repository. I had a bash script that was provided with my code, together with a small instruction in the README.md file that was explaining how to symlink that file into the .git/hooks folder. It did work, but it needed manual interaction first in order to be able to do its work.

Meet PHP Composter (or It Which Has Been Misnamed)

To improve upon this, I have written a Composer package that acts as a custom installer for Git hooks. It has the very silly name of PHP Composter, which is a word play on both “Composer” and “Post-Commit” (unfortunately, the package name PrePosterous was already taken).

You wouldn’t use this package directly, though, as it will not do much. Instead, you will use packages of the type php-composter-action, that depend upon the above package to be installed and configured as Git hooks. Through a combination of custom Composer installer code, the "extra" key, and lots of intermediary files and symbolic links, this enables automatically attaching PHP methods to Git hooks.

The benefits of this approach

  • Code to be executed through Git hooks is correctly defined and used as a development dependency. For production deployments (using composer install --no-dev), the entire PHP Composter stuff is simply ignored, without leaving any traces.
  • The dependency chain is used in the correct order. Your project does not depend on an executable like PHP CodeSniffer, but rather on the one action that needs to be executed upon commit. This action then takes care of whatever stuff it needs.
  • The onboarding process for new developers is simplified, as they don’t need to “create symlinks“, “copy binaries“, or whatever else is usually done. A single composer install takes care of all that is needed to start developing.
  • The Git hooks can be written in PHP instead of Bash, which is probably preferable for developers using Composer.
  • The wiring up of the Git hooks and the tools used by them is done through Composer‘s Autoloader, which avoids messing around with the local system’s PATH settings and environment variables.
  • As Composer‘s Autoloader is used, this also means that a large project with several dozen libraries will still only pull in each dependency like PHP CodeSniffer once, instead of having small Bash scripts duplicating across all projects.
  • The actual actions can be centrally maintained and updated, and a simple composer update will incorporate any changes into existing packages.

If you’re not interested in knowing more about the inner workings of this system, you don’t need to read the rest of this blog post and should head straight to the Existing PHP Composter Actions section of PHP Composter‘s README file. There’s only one existing action as of the time of this writing (checking PSR-2 Coding Style Guide compliance through PHP CodeSniffer), and a second one is waiting for a pull request to be merged into PHP CodeSniffer to work correctly (checking WordPress Coding Standards compliance through PHP CodeSniffer).

Technical Details

To make this work, PHP Composter will first tell Composer to use an alternative installation path for packages of type php-composter-action; instead of being installed into vendor//, they will be installed into .git/php-composter/actions/.

Then, PHP Composter will look through the package’s "extra" key in composer.json to find a key named "php-composter-hooks" which contains mappings between Git hook names and PHP methods. It will then create a file .git/php-composter/config.php that collects the mappings of all such packages.

<?php
// PHP Composter configuration file.
// Do not edit, this file is generated automatically.
// Timestamp: 2016/03/28 05:03:19

return array(
    'applypatch-msg' => array(
    ),
    'pre-applypatch' => array(
    ),
    'post-applypatch' => array(
    ),
    'pre-commit' => array(
        10 => array(
            'PHPComposter\PHPComposter_PHPCS_PSR2\Sniffer::preCommit',
        ),
    ),
    'prepare-commit-msg' => array(
    ),
    'commit-msg' => array(
    ),
    'post-commit' => array(
    ),
    'pre-rebase' => array(
    ),
    'post-checkout' => array(
    ),
    'post-merge' => array(
    ),
    'post-update' => array(
    ),
    'pre-auto-gc' => array(
    ),
    'post-rewrite' => array(
    ),
    'pre-push' => array(
    ),
);

Then, it will create symbolic links from each Git hook ( .git/hooks/) to the Bash script vendor/php-composter/php-composter/bin/php-composter.

#!/bin/bash

# ------------------------------------------------------------
# Git Hooks Management through Composer.
#
# @package   PHPComposter\PHPComposter
# @author    Alain Schlesser <alain.schlesser@gmail.com>
# @license   MIT
# @link      http://www.brightnucleus.com/
# @copyright 2016 Alain Schlesser, Bright Nucleus
# ------------------------------------------------------------

# Collect environment data.
hook=`basename "$0"`
package_root=`pwd`

# Make sure we have a PHP binary.
php=`which php`
if [[ $? -ne 0 ]]; then
    printf "PHP binary could not be found. PHP Composter aborted.\n"
    exit
fi

# Verify that the bootstrap.php file exists.
if [[ ! -f $package_root/.git/php-composter/includes/bootstrap.php ]]; then
    printf "PHP Composter bootstrap file was not found. Please reinstall.\n"
    exit
fi

# Launch the engine.
$php $package_root/.git/php-composter/includes/bootstrap.php $hook $package_root

This shell script is only a small wrapper to launch the file .git/php-composter/includes/bootstrap.php (a symbolic link to vendor/php-composter/php-composter/includes/bootstrap.php), which is responsible for parsing the previously generated configuration file and calling the corresponding action.

<?php
/**
 * Git Hooks Management through Composer.
 *
 * @package   PHPComposter\PHPComposter
 * @author    Alain Schlesser <alain.schlesser@gmail.com>
 * @license   MIT
 * @link      http://www.brightnucleus.com/
 * @copyright 2016 Alain Schlesser, Bright Nucleus
 */

use PHPComposter\PHPComposter\Paths;

// Get the command-line arguments passed from the shell script.
global $argv;
$hook = $argv[1];
$root = $argv[2];

// Initialize Composer Autoloader.
if (file_exists($root . '/vendor/autoload.php')) {
    require_once $root . '/vendor/autoload.php';
}

// Read the configuration file.
$config = include Paths::getPath('git_config');

// Iterate over hook methods.
if (array_key_exists($hook, $config)) {

    $actions = $config[$hook];

    // Sort by priority.
    ksort($actions);

    // Launch each method.
    foreach ($actions as $calls) {
        foreach ($calls as $call) {

            // Make sure we could parse the call correctly.
            $array = explode('::', $call);
            if (count($array) !== 2) {
                throw new RuntimeException(
                    sprintf(
                        _('Configuration error in PHP Composter data, could not parse method "%1$s"'),
                        $call
                    )
                );
            }
            list($class, $method) = $array;

            // Instantiate a new action object and call its method.
            $object = new $class($hook, $root);
            $object->init();
            $object->$method();
            $object->shutdown();
            unset($object);
        }
    }
}

As you can see from the code above, this will get the hook name from the small Bash script that was executed, iterate over the configuration file to find the corresponding action, instantiate the action’s class, and finally execute that action.

This whole “symlinking business” is done on each composer install and composer update to always keep your Git hooks current, and to avoid having stale links (for example, by removing the vendor folder), Composer is always told that the actions are not yet installed, to force a complete redeploy on each change.

This is still a very early release for PHP Composter, so I appreciate any feedback. I do think that this is very useful and I will incorporate it into all of my projects. I would be happy to hear about any other uses of PHP Composter out in the wild, so do let me know of your specific use cases!

Leave a Comment