Automating tasks with Grunt and Git hooks

Using Grunt and Git to speed up a more automatic workflow (Grunt and Git, sitting in a tree, K-I-S-S-I-N-G)

Requirements

To be able to use this article, you are expected to have Grunt and Git installed.

Git Hooks

What are Git Hooks?

Git hooks are pre-defined scripts that run at particular points in the git workflow. They are stored in the folder .git/hooks, and each of them is named for the particular action they hook into, like pre-commit, post-commit, post-merge, etc. The language they’re written in is somewhat irrelevant, as long as they’re a valid shell script of one flavour or another.

How can they be used?

How exactly each hook can be used depends, obviously, on where it gets triggered in the flow. If you look in the folder .git/hooks, there are several scripts ending with .sample. Not too surprisingly, each of these is a sample for a hook, with something that the good people who work on Git feels might be useful. They are not activated by default, you would have to remove the .sample. Note that they are also not an exhaustive list of hooks.

The hooks come in two flavours: Some can stop the particular action they hook into (such as pre-commit and commit-msg, which both will cancel the commit if they exit with a non-zero exit code), while others react to the action they hook into, but cannot stop it from happening.

The main issue with git hooks is that not only are they on a per-repository basis, they are on a local per-repository basis, which means that you have to in some way automate copying the hooks over to the hooks directory after cloning the project, and you also need to automate ensuring that the hooks are up-to-date. Of course, it is possible to leave it up to each user to remember to activate the hooks, but we can do better than that, no?

Enter Grunt, stage left!

Grunt Githooks

The Githooks task by Romaric Pascal takes a few config options (including the possibility of a specialized template), and when you run grunt githooks, it creates the hooks you’ve defined. By default they’re written in NodeJS, since that’s cross-OS and will be installed if you’re running grunt tasks. You can define that it should be another language, however, especially if you’re wanting to add the hook to a script already defined.

As this article would be far too exhaustive with all options, details of the tasks can be found linked in the references below.

To install, run npm install --save-dev grunt-githooks in the terminal, just like any other grunt task.

Example git hooks

To get the full source code, refer to this github repository. It runs three different hooks, one of which is running a grunt task.

All templates/config files for the tasks are expected to reside in hooks, and outside of that it follows the standard Webapp generator of yeoman, with some of the unnecessary cruft cleaned away.

Pre-commmit

This is the most common git hook I’ve used. I want to ensure that nothing silly gets into the live site, where “silly” can range from syntax errors, to whitespace errors, to breaking tests, all depending on the particular situation of your project. Mine assumes you have a task in your Gruntfile named commit, with whichever tasks are relevant for your project.

grunt.initConfig({
    githooks: { // Definition of the task config
        stage: { // Name it something relevant
            options:{
                template: 'hooks/stage.js.hbs' // the Handlebars template the hook is rendered from
            },
            // Each hook can be defined here with whatever task is used
            'pre-commit': 'commit' 
        }
    }
)};

Now, based on the article by Garrett Murphey I came to a few conclusions on how I want to build up the hook. In particular, the pre-commit tasks should:

  • Not run on rebase
  • Only run on staged changes

With help from Romaric Pascal I found this article which shows how to accomplish the second point, and with some experimentaion I accomplished the first point in NodeJS (using execSync and the exec function rather than the run function).

var exec = require('child_process').exec;
// https://npmjs.org/package/execSync
// Executes shell commands synchronously
var sh = require('execSync').run;
var branchName = require('execSync').exec('git branch | grep \'*\' | sed \'s/* //\'').stdout;

// Don't run on rebase
if (branchName !== '(no branch)') {
    exec('git diff --cached --quiet', function (err, stdout, stderr) {
        /*jshint unused: false*/
        'use strict';

        // only run if there are staged changes
        // i.e. what you would be committing if you ran "git commit" without "-a" option.
        if (err) {
            // stash unstaged changes - only test what's being committed
            sh('git stash --keep-index --quiet');

            exec('grunt {{task}}', function (err, stdout, stderr) {
                console.log(stdout);

                // restore stashed changes
                sh('git stash pop --quiet');

               var exitCode = 0;
               if (err) {
                   console.log(stderr);
                   exitCode = -1;
               }
               process.exit(exitCode);
           });
        }
    });
}

Note the /*jshint unused: false*/ in the first call to exec. It’s there because otherwise jshint (yes, I lint my hooks as thoroughly as I do my other JavaScript files) would object to some of the variables not being used.

Could you test for other things in the template? Of course, this is a start to give you a feel for it. As long as there’s a specific pass/fail condition and a fail exits with a non-zero code you can test for other things. The sample hook also tests for non-ascii file names and whitespace errors.

Running updates of dependencies when they’ve changed

Okay, so the title of this section might be slightly of a misnomer. It’s not technically it running updates whenever a dependency has changed, but rather that when you pull from origin (or, for that matter, from upstream), you want it to automatically update any dependencies if there have been changes.

As each project might have different files that should be checked, the tasks reads a JSON-file, hooks/data/update.json, which holds several objects with a file and a command defined.

[
    {
        "file": "package.json",
        "command": "npm update"
    }
]

The template, inspired by this post on StackOverflow uses git diff to check if the file has changed, and if it has, it will log a response to that effect before running the update command.

var object = require('../../hooks/data/update');
for (var i in object) {
    file = object[i].file;
    command = object[i].command;
    fileChanged = (shOutput('git diff HEAD@{1} --stat -- ' + file + ' | wc -l').stdout > 0); 

    if (fileChanged) {
        console.log(file + ' has changed, dependencies will be updated.');
        sh(command);
    }
}
process.exit(0);

Note that this doesn’t use any grunt tasks at all. There is no technical way to deal with that in the current task, but you don’t really need to use the bound values in your template unless you want to. The various options for binding values to the template are based on using the default template supplied by this task.

Okay, so that deals with the options and the template. What about the hooks?Well, depending on how you call thegit pull`, it will trigger one out of two hooks.

Git pull, no arguments

A git pull without arguments is a git fetch followed by a git merge, so the best place for an update hook in that case is the post-merge hook.

Git pull –rebase

If you’re using --rebase, on the other hand, it runs git fetch followed by git rebase, which means that the post-checkout hook is the proper place for one. This, obviously, means we’ll need to bind two hooks to the same template. We can do that!

grunt.initConfig({
    githooks: { // Definition of the task config
       update: { // Name it something relevant
            options: {
                template: 'hooks/update.js.hbs' // the Handlebars template the hook is rendered from
            },
            // Each hook can be defined here with whatever task is used
            'post-merge': true,
            'post-checkout': true 
        }
    }
)};

Moving forward

Alright, so now we have three hooks that gets ran at different points in the workflow. From what I’ve read in the docs of grunt githooks you might be able to define several snippet-templates and mix-and-match, but I haven’t yet explored that.

One final thing to ease the use of this, however:

Automatically running grunt githooks after npm is installed

So, we’ve automated it running linting/tests/etc before every commit, and that it will check if dependencies needs to be updated after every pull. We still need to run grunt githooks to initiate the hooks, yes?

Yes, but NodeJS is smart and can do it for us, by defining a postinstall script in package.json. Just paste the following lines into your package.json file, and running npm install will automatically run grunt githooks.

"scripts": {
     "postinstall": "grunt githooks"
}

References


For the moment comments are not enabled, but feel free to reach out on Twitter.