Utilizando git hooks para validar nuestro código PHP

El sábado pasado se celebró el phpDay, evento al que pude asistir junto a mis compañeros de trabajo. Las ponencias fueron muy interesantes, en particular a mi me gustaron las de Domain-Driven Design por Carlos Buenosvinos, dealing with fear in legacy code por Aitor Suso y PHP7 por Albert Casademont, desde aquí mi enhorabuena por la calidad del contenido de sus ponencias.

El tema es que salí de las ponencias con muchas ideas en la cabeza, teorías conspiratorias contra mi propio código y con la certeza de haber dado con las claves para dar un salto de calidad en cuanto a desarrollo se refiere.

Una de las cosas que salió en la conferencia era algo tan evidente, que incluso me molesta no haberlo pensado/hecho/investigado antes, esto es, mezclar validación de código PHP con los git hooks.

Para los que no lo sepan, los git hooks son eventos que podemos ejecutar automáticamente cuando utilizamos un software de control de versiones como GIT, es decir, una vez instalados, los asociamos a un evento de GIT y ya no debemos preocuparnos más que por los resultados que de su ejecución se desprendan..

Vale si, flipé con los git hooks, ¿pero a quién pueden beneficiar y que tienen que ver con PHP? Le puede beneficiar a cualquier programador, especialmente si forma parte de un equipo. Y sobretodo va a ayudar a aquellos programadores que no tienen tiempo para dedicar a pequeños detalles como los estándares de programación o los tests unitarios. La realidad es que si no lo hacemos, acabamos revisitando nuestro código varias veces, produciendo código mal estructurado, complicado de entender, difícil de mantener y que a la larga acaba provocando «¿Cómo diablos pude hacer esto así?».

Voy a asumir que tienes GIT instalado y que dispones de un proyecto clonado de un repositorio de GIT, así que pasaremos directamente a la integración y uso de los hooks. Este artículo se ha escrito utilizando un Fedora20 como SO para las pruebas.

1) Descargamos Composer, nuestro instalador personal de paquetes PHP

Hay que descargar Composer para instalar todos los paquetes necesarios para nuestra instalación:

root@samurai:/usr/local/bin$ cd /usr/local/bin

root@samurai:/usr/local/bin$ curl -sS https://getcomposer.org/installer | php

root@samurai:/usr/local/bin$ chmod a+x composer.phar

root@samurai:/usr/local/bin$ mv /usr/local/bin/composer.phar /usr/local/bin/composer

2) Creamos el fichero de configuración que necesitará composer

Basta con crear un fichero composer.json, ubicado en la raíz de nuestro proyecto y escribir lo siguiente en el:

samurai@samurai:/var/www/tu-proyecto$ touch composer.json

samurai@samurai:/var/www/tu-proyecto$ vi composer.json

{
    "name": "code-validator",
    "require-dev": {
        "phpunit/phpunit": "4.4.*",
        "squizlabs/php_codesniffer": "2.*",
        "phpmd/phpmd" : "@stable",
        "fabpot/php-cs-fixer": "dev-master",
        "halleck45/phpmetrics": "@stable"
    },
    "scripts": {
        "post-install-cmd": [
            "bash setup.sh"
        ]
    }
}

En require-dev tenemos las librerías que vamos a utilizar para nuestra validación de código y además disponemos de un fichero bash de Linux, el cuál se va a ejecutar cada vez que lancemos composer.

3) Contenido fichero setup.sh

samurai@samurai:/var/www/tu-proyecto$ touch setup.sh

samurai@samurai:/var/www/tu-proyecto$ vi setup.sh

#!/bin/sh

path=`pwd`

cp $path/../phplibs/code-validator/hooks/pre-commit.php $path/.git/hooks/pre-commit
chmod +x $path/.git/hooks/pre-commit

En mi entorno dispongo de un directorio phplibs, en el cuál guardo todas aquellas «tools» PHP que puedo utilizar entre diferentes proyectos, puedes poner otras rutas o incluso una ruta de tu propio proyecto.

En este caso, he creado un fichero setup.sh que se ejecutará con cada actualización que hagamos mediante composer, de forma que siempre dispondré de un mismo pre-commit tool actualizado para todos mis proyectos.

4) ¿Que hace nuestro script de pre-commit?

Nuestro script de pre-commit va a ser el encargado de validar nuestro código PHP

#!/usr/bin/php
<?php

/**
 * This script is expected to be executed on ./git/hooks directory from your project path
 */

require realpath(dirname(__FILE__) . '/../../../phplibs/Output.php');

class CodeQualityTool
{
    private $output;

    const PHP_FILES_IN_LIBRARY = '/^library\/(.*)(\.php)$/';
    const PHP_FILES_IN_APPLICATION = '/^application\/(.*)(\.php)$/';
    const PHP_FILES_IN_SCRIPTS = '/^scripts\/(.*)(\.php)$/';

    public function __construct()
    {
        $this->output = new Output();
    }

    public function run()
    {
        $this->output->echoC('Viko Tech Code Quality Tool', 'black', 'yellow');
        $this->output->echoC('Fetching PHP files', 'green');
        $files = $this->filterPhpFiles($this->extractCommitedFiles());

        $this->output->echoC('Check composer', 'green');
        $this->checkComposer($files);

        try {
            $this->output->echoC('Running PHPLint', 'green');
            if (!$this->phpLint($files)) {
                throw new Exception('There are some PHP syntax errors!');
            }

            $this->output->echoC('Checking code style', 'green');
            if (!$this->codeStyle($files)) {
                throw new Exception('There are coding standards violations!');
            }

            $this->output->echoC('Checking code style with PHPCS', 'green');
            if (!$this->codeStylePsr($files)) {
                throw new Exception('There are PHPCS coding standards violations!');
            }

            $this->output->echoC('Checking code mess with PHPMD', 'green');
            if (!$this->phPmd($files)) {
                throw new Exception('There are PHPMD violations!');
            }

            $this->output->echoC('Running unit tests', 'green');
            if (!$this->unitTests()) {
                throw new Exception('Don\'t be lazy, fix your unit tests!');
            }
        } catch(Exception $e) {
            $this->output->echoC($e->getMessage(), 'white', 'red');
            exit(1);
        }

        $this->output->echoC('Let\'s do a commit!', 'white', 'green');
    }

    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()
    {
        $files = array();
        $against = 'HEAD';

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

        return $files;
    }

    private function filterPhpFiles($files = array())
    {
        foreach ($files as $idx => $file) {
            $libraryFile = preg_match(self::PHP_FILES_IN_LIBRARY, $file);
            $applicationFile = preg_match(self::PHP_FILES_IN_APPLICATION, $file);
            $scriptFile = preg_match(self::PHP_FILES_IN_SCRIPTS, $file);

            if (!$libraryFile && !$applicationFile && !$scriptFile) {
                unset($files[$idx]);
            }
        }

        return $files;
    }

    private function phpLint($files)
    {
        $succeed = true;

        foreach ($files as $file) {
            $output = '';
            exec("php -l $file", $output, $returnCode);

            if ($returnCode !== 0) {
                foreach ($output as $line) {
                    $this->output->echoC($line, 'red');
                }

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

        return $succeed;
    }

    private function codeStyle(array $files)
    {
        $succeed = true;
        $fixers = 'eof_ending,indentation,linefeed,lowercase_keywords,trailing_spaces,short_tag,php_closing_tag,extra_empty_lines,elseif,function_declaration,operators_spaces';
        $needleFixers = str_replace(',', '|', "/$fixers/");

        foreach ($files as $file) {
            $output = '';
            exec(__DIR__ . "/../../vendor/bin/php-cs-fixer -v --dry-run fix $file --fixers=$fixers", $output, $returnCode);

            if ($returnCode !== 0) {
                foreach ($output as $line) {
                    if (preg_match($needleFixers, $line)) {
                        $this->output->echoC($line, 'red');
                    }
                }

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

        return $succeed;
    }

    private function codeStylePsr(array $files)
    {
        $succeed = true;
        $needleError = '/(ERROR\s+\|)/i';
        $needleWarning = '/(WARNING\s+\|)/i';

        foreach ($files as $file) {
            $color = null;
            $output = '';
            exec(__DIR__ . "/../../vendor/bin/phpcs --standard=PSR2 $file", $output, $returnCode);

            if ($returnCode !== 0) {
                foreach ($output as $line) {
                    if (preg_match($needleError, $line)) {
                        $color = 'red';
                    } elseif (preg_match($needleWarning, $line)) {
                        $color = 'brown';
                    } elseif (preg_match('/---/', $line)) {
                        $color = null;
                    }

                    $this->output->echoC($line, $color);
                }

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

        return $succeed;
    }

    private function phPmd($files)
    {
        $succeed = true;

        foreach ($files as $file) {
            $output = '';
            exec(
            __DIR__ . "/../../vendor/bin/phpmd $file text codesize, controversial, design, naming, unusedcode",
            $output,
            $returnCode);

            if ($returnCode !== 0) {
                foreach ($output as $line) {
                    if (!empty($line)) {
                        $this->output->echoC($line, 'red');
                    }
                }

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

        return $succeed;
    }

    private function unitTests()
    {
        $needleTest = '/(\d+\)\s+)/i';
        $needleCause = '/(Caused by|FAILURES)/i';

        $output = '';
        exec(__DIR__ . '/../../vendor/bin/phpunit -c tests/phpunit.xml', $output, $returnCode);

        if ($returnCode !== 0) {
            foreach ($output as $line) {
                $color = null;

                if (preg_match($needleTest, $line)) {
                    $color = 'blue';
                } elseif (preg_match($needleCause, $line)) {
                    $color = 'red';
                }

                $this->output->echoC($line, $color);
            }
        }

        return $returnCode === 0;
    }
}

$codeQualityTool = new CodeQualityTool();
return $codeQualityTool->run();

En este código, existe la librería Output, que básicamente se encarga de colorear la salida de nuestro script en el terminal.

<?php

class Output
{
	private $foregroundColors = array();
	private $backgroundColors = array();

	public function __construct()
	{
		// Set up shell colors
		$this->foregroundColors['black'] = '0;30';
		$this->foregroundColors['dark_gray'] = '1;30';
		$this->foregroundColors['blue'] = '0;34';
		$this->foregroundColors['light_blue'] = '1;34';
		$this->foregroundColors['green'] = '0;32';
		$this->foregroundColors['light_green'] = '1;32';
		$this->foregroundColors['cyan'] = '0;36';
		$this->foregroundColors['light_cyan'] = '1;36';
		$this->foregroundColors['red'] = '0;31';
		$this->foregroundColors['light_red'] = '1;31';
		$this->foregroundColors['purple'] = '0;35';
		$this->foregroundColors['light_purple'] = '1;35';
		$this->foregroundColors['brown'] = '0;33';
		$this->foregroundColors['yellow'] = '1;33';
		$this->foregroundColors['light_gray'] = '0;37';
		$this->foregroundColors['white'] = '1;37';

		$this->backgroundColors['black'] = '40';
		$this->backgroundColors['red'] = '41';
		$this->backgroundColors['green'] = '42';
		$this->backgroundColors['yellow'] = '43';
		$this->backgroundColors['blue'] = '44';
		$this->backgroundColors['magenta'] = '45';
		$this->backgroundColors['cyan'] = '46';
		$this->backgroundColors['light_gray'] = '47';
	}

    /**
     * Returns colored string
     *
     * @param string $string
     * @param string $foregroundColor
     * @param string $backgroundColor
     */
	public function echoC($string, $foregroundColor = null, $backgroundColor = null)
	{
		$coloredString = '';

		// Check if given foreground color found
		if (isset($this->foregroundColors[$foregroundColor])) {
			$coloredString .= "\033[" . $this->foregroundColors[$foregroundColor] . "m";
		}
		// Check if given background color found
		if (isset($this->backgroundColors[$backgroundColor])) {
			$coloredString .= "\033[" . $this->backgroundColors[$backgroundColor] . "m";
		}

		// Add string and end coloring
		$coloredString .=  $string;

		if (!is_null($foregroundColor) || !is_null($backgroundColor)) {
		    $coloredString .= "\033[0m";
		}

		echo $coloredString . PHP_EOL;
	}

    /**
     * Returns all foreground color names
     *
     * @return array
     */
	public function getForegroundColors()
	{
		return array_keys($this->foregroundColors);
	}

    /**
     * Returns all background color names
     *
     * @return array
     */
	public function getBackgroundColors()
	{
		return array_keys($this->backgroundColors);
	}
}

Y con esto ya estamos casi listos para verificar nuestro código automáticamente con cada commit.

5) Instalamos los paquetes con Composer

Ejecutamos nuestro composer así:

samurai@samurai:/var/www/tu-proyecto$ composer install

Esto creará un directorio «vendor» dentro de nuestro proyecto, y en el creará los directorios necesarios para alojar los paquetes descargados.

6) Commit de código

Ahora ya puedes empezar a programar de nuevo sobre tu proyecto PHP, cuando termines lleva tus cambios a git:

samurai@samurai:/var/www/tu-proyecto$ git add .

* si estás en la raíz de tu proyecto, y un

samurai@samurai:/var/www/tu-proyecto$ git commit

Si todo ha ido bien deberíamos ver como nuestro validador va ejecutando los diferentes filtros y finalmente nos da la opción de poder introducir la descripción de nuestro commit, siempre y cuando no haya encontrado ningún tipo de error o vulneración de estándares en nuestro código, por ejemplo:


samurai@samurai:/var/www/tu-proyecto$ git commit
Viko Tech Code Quality Tool
Fetching PHP files
Check composer
Running PHPLint
Checking code style
Checking code style with PHPCS

FILE: /var/www/tu-proyecto/apps/Bootstrap.php
----------------------------------------------------------------------
FOUND 4 ERRORS AFFECTING 3 LINES
----------------------------------------------------------------------
  43 | ERROR | [x] Expected 1 newline after opening brace; 2 found
  96 | ERROR | [x] Multi-line function call not indented correctly;
     |       |     expected 8 spaces but found 12
  96 | ERROR | [x] Closing parenthesis of a multi-line function call
     |       |     must be on a line by itself
 189 | ERROR | [x] TRUE, FALSE and NULL must be lowercase; expected
     |       |     "true" but found "TRUE"
----------------------------------------------------------------------
PHPCBF CAN FIX THE 4 MARKED SNIFF VIOLATIONS AUTOMATICALLY
----------------------------------------------------------------------

Time: 244ms; Memory: 6.75Mb

There are PHPCS coding standards violations!

Si resulta, que después del esfuerzo invertido en añadir nuestro fantástico hook, queremos saltarnos todas las barreras y comitear código…podemos hacerlo, basta con lo siguiente:

samurai@samurai:/var/www/tu-proyecto$ git commit –no-verify

Con esto ya tenemos la información suficiente y las herramientas necesarias para producir un código de mayor calidad que ayer.

2 comentarios en “Utilizando git hooks para validar nuestro código PHP

  1. Oriol Chias

    Muy interesante!

    Otra utilidad muy práctica de los Hooks es automatizar el deploy de código al servidor después de un PUSH al repo GIT.

    Yo lo hago con el siguiente comando en el fichero ( /ruta_del_gitrepo/proyecto/hooks/post-receive ):

    #!/bin/bash
    GIT_WORK_TREE=/var/www/html/ruta_al_docroot_del_proyecto git checkout -f

    Si el destino deseado es otro server, se podría añadir lo siguiente:

    #!/bin/bash
    GIT_WORK_TREE=/ruta_temporal git checkout -f
    scp -r /ruta_temporal user@liveserver:/var/www/html/ruta_al_docroot_del_proyecto

    Responder

Deja un comentario