Git pre-commit hook : Vérification de fichiers PHP et Shell avant de les commiter

Quand on utilise un gestionnaire de sources comme Git ou Subversion (si vous n’en utilisez pas pour vos développements, vous connaissez la priorité n°1 sur votre liste de tâches), il est très facile de faire en sorte que les fichiers que l’on cherche à commiter sur le serveur soient vérifiés avant d’être effectivement acceptés.
Cela permet d’éviter de propager du code incorrect, qui ne compile pas, avant même qu’il ne se retrouve sur le dépôt de sources (et donc chez les autres développeurs voire dans un processus d’intégration continue).

J’utilise Git avec un fournisseur de service SaaS (Github, mais j’ai aussi utilisé Bitbucket), et je suis donc obligé de mettre en place le script de pre-commit sur le poste de développement local. À la racine d’un repository Git, il y a un sous-répertoire “.git/hooks” qui sert à contenir les hooks, c’est-à-dire les programmes à exécuter en fonction de l’action effectuée. Par défaut, ce répertoire contient des exemples de scripts dont vous pouvez vous inspirer pour créer les vôtres (en shell).

Idéalement, il faudrait vérifier la syntaxe des fichiers quand on les ajoute avec “git add” ; malheureusement, il n’y a pas de hook sur cette étape. On est donc obligé d’attendre le moment où on tente de commiter les fichiers, mais cela implique qu’en cas d’erreur il faudra ajouter de nouveau la version corrigée du fichier, puis de retenter le commit.

Pour prendre la main juste avant que le commit ne soit effectué, il faut écrire un script nommé “pre-commit”, et le déposer dans le répertoire “.git/hooks” de votre repository. Ce script doit retourner une valeur égale à zéro si tout s’est bien passé, et une valeur supérieure à zéro si on a rencontré une erreur dans un fichier qui s’apprête à être commité.

Mon hook a pour but de vérifier que les fichiers PHP que je commite sont corrects du point de vue syntaxique (testé avec la commande “php -l ”), ainsi que les scripts shell (en utilisant l’outil shellcheck). Si des erreurs sont trouvées dans certains fichiers, elles sont affichées et le commit est interrompu.

Le hook est codé en PHP. Avant j’utilisais des scripts shell, mais je suis passé au PHP pour deux raisons : certains traitements sont plus simples en PHP qu’en shell (gestion des tableaux, manipulation de chaînes…) ; et j’ai ajouté la vérification de syntaxe pour des templates Smarty (le seul moyen de vérifier la syntaxe d’un template Smarty est d’interpréter le template, et ça ne peut être fait qu’avec du code PHP).

Version sans vérification Smarty

Pour commencer, je vais vous donner le code de mon script de pre-commit sans la vérification des fichiers Smarty (tout le monde n’en a pas besoin).

Vous pourrez remarquer que ce script utilise l’objet ANSI de ma bibliothèque FineBase, afin d’écrire dans la console en utilisant les directives ANSI (pour afficher des messages en gras, en couleur, etc.).
Vous pouvez aussi voir que ce script s’attend à ce que le programme shellcheck soit installé dans le répertoire /usr/bin (répertoire d’installation par défaut sur Ubuntu, mais aussi sur la plupart des distributions Linux).

#!/usr/bin/php
<?php

require_once('finebase/Ansi.php');

// repo root path
$rootPath = exec('git rev-parse --show-toplevel', $res, $ret);
if ($ret) {
        exit(0);
}
unset($res);

// get list of files
exec('git diff --cached --name-only --diff-filter=ACMR', $files);
if (!is_array($files)) {
        exit(0);
}
// array of errors
$errors = [
        'php' => [],
        'tpl' => [],
        'sh'  => [],
];
// process all files
$smarty = $compileDir = $cacheDir = null;
foreach ($files as $file) {
        if (substr($file, -4) == '.php') {
                // check PHP file syntax
                exec("git show :$file | php -l > /dev/null", $errors['php'], $ret);
        } else {
                // check inside the file
                $fp = fopen("$rootPath/$file", 'r');
                $shebang = trim(fgets($fp));
                fclose($fp);
                if ($shebang != '#!/bin/bash' && $shebang != '#!/bin/sh')
                        continue;
                // check shell file syntax
                if (!file_exists('/usr/bin/shellcheck')) {
                        print(Ansi::color('yellow', "⚠ Can't check shell script. You should install 'shellcheck'."));
                        continue;
                }
                exec("/usr/bin/shellcheck -s bash -f gcc $rootPath/$file", $checklines, $ret);
                if (!$ret)
                        continue;
                foreach ($checklines as $checkline) {
                        $check = explode(':', $checkline);
                        if (trim($check[4]) == 'error') {
                                $errors['sh'][] = $checkline;
                        }
                }
        }
}
// display errors
$return = 0;
if (!empty($errors['php'])) {
        print(Ansi::color('red', "⛔ ERRORS IN PHP FILE(S)\n"));
        foreach ($errors['php'] as $err) {
                print(" $err\n");
        }
        $return = 1;
}
if (!empty($errors['sh'])) {
        print(Ansi::color('red', "⛔ ERRORS IN SHELL FILE(S)\n"));
        foreach ($errors['sh'] as $err) {
                print(" $err\n");
        }
        $return = 2;
}
exit($return);

Version avec vérification Smarty

Cette version est juste un peu plus complète. Elle remonte toutes les erreurs de syntaxe que pourraient contenir les fichiers PHP, Shell et Smarty qu’on s’apprête à commiter.

#!/usr/bin/php
<?php

require_once('finebase/Ansi.php');
require_once('smarty3/Smarty.class.php');

// repo root path
$rootPath = exec('git rev-parse --show-toplevel', $res, $ret);
if ($ret) {
        exit(0);
}
unset($res);

// get list of files
exec('git diff --cached --name-only --diff-filter=ACMR', $files);
if (!is_array($files)) {
        exit(0);
}
// array of errors
$errors = [ 
        'php' => [], 
        'tpl' => [], 
        'sh'  => [], 
];
// process all files
$smarty = $compileDir = $cacheDir = null;
foreach ($files as $file) {
        if (substr($file, -4) == '.php') {
                // check PHP file syntax
                exec("git show :$file | php -l > /dev/null", $errors['php'], $ret);
        } else if (substr($file, -4) == '.tpl') {
                // check Smarty file syntax
                if (!$smarty) {
                        $compileDir = sys_get_temp_dir() . '/git_commit_smarty_compile';
                        $cacheDir = sys_get_temp_dir() . '/git_commit_smarty_cache';
                        if (!is_dir($compileDir)) {
                                mkdir($compileDir);
                        }
                        if (!is_dir($cacheDir)) {
                                mkdir($cacheDir);
                        }
                        $smarty = new \Smarty();
                        $smarty->compile_dir = $compileDir;
                        $smarty->cache_dir = $cacheDir;
                        $smarty->error_reporting = E_ALL & ~E_NOTICE;
                        // add here your list of Smarty plugin path
                        $pluginPathList = ['/path/to/lib/smarty'];
                        $pluginPathList = array_merge($smarty->getPluginsDir(), $pluginPathList);
                        $smarty->setPluginsDir($pluginPathList);
                }
                try {
                        // define the root dir of your templates, which should be a sub-directory of your git repository
                        $smarty->template_dir = "$rootPath/templates/";
                        $smarty->fetch("$rootPath/$file");
                } catch (\Exception $e) {
                        $errors['tpl'][] = ">> '$rootPath/$file' ($file)";
                        $errors['tpl'][] = $e->getMessage();
                }
        } else {
                // check inside the file
                $fp = fopen("$rootPath/$file", 'r');
                $shebang = trim(fgets($fp));
                fclose($fp);
                if ($shebang != '#!/bin/bash' && $shebang != '#!/bin/sh') {
                        continue;
                }
                // check shell file syntax
                if (!file_exists('/usr/bin/shellcheck')) {
                        print(Ansi::color('yellow', "⚠ Can't check shell script. You should install 'shellcheck'."));
                        continue;
                }
                exec("/usr/bin/shellcheck -s bash -f gcc $rootPath/$file", $checklines, $ret);
                if (!$ret) {
                        continue;
                }
                foreach ($checklines as $checkline) {
                        $check = explode(':', $checkline);
                        if (trim($check[4]) == 'error') {
                                $errors['sh'][] = $checkline;
                        }
                }
        }
}
// remove temporary files
if ($compileDir) {
        shell_exec("rm -rf $compileDir $cacheDir");
}
// display errors
$return = 0;
if (!empty($errors['php'])) {
        print(Ansi::color('red', "⛔ ERRORS IN PHP FILE(S)\n"));
        foreach ($errors['php'] as $err) {
                print(" $err\n");
        }
        $return = 1;
}
if (!empty($errors['sh'])) {
        print(Ansi::color('red', "⛔ ERRORS IN SHELL FILE(S)\n"));
        foreach ($errors['sh'] as $err) {
                print(" $err\n");
        }
        $return = 2;
}
if (!empty($errors['tpl'])) {
        print(Ansi::color('red', "⛔ ERRORS IN SMARTY FILE(S)\n"));
        foreach ($errors['tpl'] as $err) {
                print(" $err\n");
        }
        $return = 3;
}
exit($return);

Pensez à adapter la variable $pluginPathList en y mettant la liste des répertoires contenant vos plugins Smarty. Adaptez aussi $smarty->template_dir pour y indiquer le chemin vers le répertoire racine de vos templates Smarty (c’est utile lorsque vous faite des inclusions de fichiers en mettant des chemins relatifs à cette racine).

J’utilise ce hook depuis un certain temps maintenant. J’espère qu’il pourra aussi être utile à d’autres personnes.
En cherchant un peu sur le web, vous pourrez trouver plein de contributions du même type, qui pourront remplir vos besoins. Personnellement, je n’ai rien trouvé qui fasse les vérifications exactes dont j’avais besoin (PHP, Smarty et Shell). Donc voilà 😉

Dernière précision : Dans la mesure où ce script de pre-commit s’exécute en local sur le poste de développement, il est toujours possible de demander git à ne pas l’utiliser et donc forcer le commit des fichiers. Seuls des hook côté serveur peuvent éviter cela. Mais franchement, il n’y a strictement aucun intérêt à vouloir contourner ce type de sécurité.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Notifiez-moi des commentaires à venir via email. Vous pouvez aussi vous abonner sans commenter.