Smarter Git Hooks with --porcelain


In a previous post I introduced Git Hooks and how to make them a seamless part of the frontend dev's workflow. I'd like to build on that and talk about how to make our git hooks smarter - by using the --porcelain flag.

General Workflow

If you recall from the "Git Hooks for the Front End Developer" post, we walked through using Git + NodeJS + Gulp to make our git hooks moar awesum. We're going to follow that same workflow here, so the majority of this post will be detailing how a gulp task (and helpers) may look:
Git Hook Workflow


Git Some Help with a "Git Helper" file

One thing to keep in mind when using gulp tasks for git hooks is that most gulp tasks are set up to examine the entire set of files. For instance, when you run a jshint or eslint task, you're typically looking at all of the *.js files in your application. This, however, may not be desirable in a git hook. What if only 3 JS files changed - should you still check the styling of all files? What if your codebase is huge - then won't that make the commit process obnoxious? Fear not, my friends, for this can be overcome - let's create a helper to solve this for us :)

Here's a bit of a project structure we'll work with for this post:
/js
/gulp-tasks
/gulp-utils

In the /gulp-utils directory, let's create a file named gitHelper.js. This file will utilize the Q and gulp-git libs to make it easy to interact with git from within our gulp tasks:

var Q = require('q'),  
  git = require('gulp-git'),
  //variable to cache modified files list
  indexedFiles;

//statuses that are provided when "git status"
//is used with the --porcelain flag
//i.e. 
// M js/app.js
exports.STATUSES = {  
  MODIFIED: ' M ',
  ADDED: '?? ',
  DELETED: ' D ',
  ADDED_MODIFIED: 'AM ',
  ADDED_DELETED: 'AD '
};
//freeze so no accidental overwrites of status constants
Object.freeze(exports.STATUSES);

exports.getModifiedFiles = function() {  
  if (indexedFiles) {
    return Q.when(indexedFiles);
  }

  var defer = Q.defer();

  //--porcelain arg outputs all files 
  //  separated by new line such that file paths 
  //  are preceded by 3 char status
  git.status({
      args: '--porcelain',
      quiet: true
    }, function processStatus(err, status) {
      if (err) {
        defer.reject(err);
      } else {
        if (status.length === 0) {
          indexedFiles = [];
        } else {
          indexedFiles = status.split('\n')
            .map(function (s) {
              if (s.length < 1) {
                return null;
              }

              //grab status and file path
              var fileStatus = s.substr(0, 3),
                  filePath = s.substr(3);
              return {
                status: fileStatus,
                path: filePath
              };
            }).filter(function(f) {
              return f !== null;
            });
        }
        defer.resolve(indexedFiles)
      }
  });

  return defer.promise;
};

The purpose of this util is pretty simple. It exposes a list of all possible status codes (exports.STATUSES) and provides a then-able function for getting a list of modified file descriptors (exports.getModifiedFiles). To make things easy for consumers, the file descriptors look like this: {status: ' M ', path: 'js/app.js'}.

Notice that the only reason we are able to get the path and status for each modified file is because we are using git's --porcelain flag1.


Using the Git Helper

Next, let's build a gulp task that can use this helper to make our git hook smarter in that it only lints the modified JS files instead of all of them!

In the /gulp-tasks directory, create a file named pre-commit.js. The thought here being that our pre-commit hook script will kick this off as a child process by spawning gulp with "pre-commit" as the argument (if you're not following that, please read the introductory article):

var gulp = require('gulp'),  
  gitUtil = require('../gulp-utils/gitHelper'),
  GIT_STATUSES = gitUtil.STATUSES,
  jshint = require('gulp-jshint');

module.exports = function preCommit(done) {

  gitUtil.getModifiedFiles().then(function(files) {
    if (files.length === 0) {
      return done();
    }

    var modJsFiles = files.filter(function isJsFile(fileInfo) {
      //filter out deleted files 
      // regardless of extension
      return fileInfo.status !== GIT_STATUSES.DELETED
        //make sure they are in the /js dir 
        && /js\//.test(fileInfo.path) 
        //make sure they have a .js extension
        && /\.js/.test(fileInfo.path);
      }).map(function getPath(fileInfo) {
        //we only care about the paths at this point
        return fileInfo.path;
      });

    if (modJsFiles.length === 0) {
      return done();
    }

    console.log('\n\nLINT MODIFIED FILES', JSON.stringify(modJsFiles, null, 4), '\n');

    function noop() {}

    gulp.src(modJsFiles)
      .pipe(jshint('.jshintrc'))
      .on('error', noop)
      .on('end', done)
      .pipe(jshint.reporter('default'))
      .pipe(jshint.reporter('fail'));
    }).catch(function(e) {
        throw e;
    });
};

Notice that we are utilizing the helper, and easily filtering out any file descriptors we do not want. We then take what's left, feed it to gulp.src and pipe through jshint just as we would if linting all of the application's JS files - but we are only linting the modified files here! This makes for a smart git hook that only assesses the files that actually need an updated assessment!

I've also updated the github repo demo project to use --porcelain and only run unit tests, lints, etc.. on only modified files!

-Bradley

  1. Maybe you've also used the -s flag to see a shorter list like this, but --porcelain is preferred for getting easily parsed output for scripts - git status documentation.

Loading Google+ Comments ...