Wibbly Stuff

Improving Nodejs workflow with git hooks

It's always fascinating to see how much of the tasks you can automate. It feels good to save those few extra keystrokes. If you work in a nodejs environment, you are probably using npm and bower for dependency management, and jshint for code quality. But still, you've to run npm install and bower install manually each time the dependencies change. Same with jshint too. Many developers don't bother about jshint and just push code which doesn't validate. There must be a way to enforce this. Don't ya wish?

We all are lazy. We all want to do less work. Automating repetitive tasks is always welcome. We'll discuss how you can automate dependency management and enforce jshint with git hooks.

We can have hooks which run while doing various operations with git. You can know about all the different kind of hooks by looking at the .git/hooks folder of your project directory. Here we'll use two different hooks, pre-commit, which runs before committing and post-merge, which runs after you merge upstream to local (or pull). The pre-commit hook will ensure jshint validation, and the post-merge hook will take care of npm and bower.

The git hooks are placed under .git/hooks folder, which cannot be committed to version control. Which is a good thing, since it prevents people from executing random scripts on your machine when you clone a third-party repository. But in our case, we need to execute scripts on other people's machine. To overcome the limitation, we can get help from symbolic links. Let's create a folder named .git-hooks in our project root and place the hooks there. We'll use symlinks to set up the hooks. Note that any script added to the git hooks should be reviewed carefully.

First, we'll install all the required npm modules.

npm install --save-dev gulp gulp-jshint gulp-gitmodified gulp-sym

Next, create a file named pre-commit under the .git-hooks folder, and add the following content.

#!/usr/bin/env bash

# Check if any .js file changed
git diff --cached --name-only --diff-filter=ACM | grep '.js$' >/dev/null 2>&1

if [[ $? == 0 ]]; then
    gulp lint
fi

exit $?

Don't forget to make the file executable. Or our hook won't work.

chmod a+x pre-commit

Here, the hook checks if any javascript files have changed, and runs gulp lint. You could add any other task also, but ensure that it returns exit status 1 on failure.

Let's look at our gulp task named lint. We list all the javascript files except minified ones and the ones under node_modules or bower_components, and run jshint on them. jshint.reporter("fail") ensures that gulp returns the exit status 1 on failure, which is needed for our hook.

var gulp = require("gulp"),
    jshint = require("gulp-jshint"),
    gitmodified = require("gulp-gitmodified");

// Lint JavaScript files
gulp.task("lint", function() {
    return gulp.src([
        "**/*.js", "!**/*.min.js",
        "!node_modules/**", "!bower_components/**"
    ])
    .pipe(gitmodified("modified"))
    .pipe(jshint())
    .pipe(jshint.reporter("jshint-stylish"))
    .pipe(jshint.reporter("fail"))
    .on("error", gutil.log);
});

If jshint validation fails, then the developer won't able to commit the changes unless he fixes the issues first. So, there is less chance committing bad code.

You can also add other things like jscs to enforce code style. It's always good to have consistent code in your project.

Next, we need to add a symlink .git/hooks/pre-commit which points to .git-hooks/pre-commit. We'll automate it later.

Now, we'll add the post-merge hook. Let's create a file named post-merge under the .git-hooks folder and add the following content.

#!/usr/bin/env bash

# List changed files
changed_files="$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)"

check_file() {
    echo "$changed_files" | grep --quiet "$1" && eval "$2"
}

# `npm install` and `npm prune` if the `package.json` file gets changed
check_file "package.json" "npm install && npm prune"

# `bower install` and `bower prune` if the `bower.json` file gets changed
check_file "bower.json" "bower install && bower prune"

Also, don't forget to make the file executable.

chmod a+x post-merge

Here, the hook checks if package.json or bower.json have changed, and runs npm install or bower install accordingly. Now, we need to add a symlink .git/hooks/post-merge which points to .git-hooks/post-merge.

To ensure that these hooks are used, we need to set it up automatically in all developers systems. First, let's add a gulp task which creates the symlinks.

var gulp = require("gulp"),
    symlink = require("gulp-sym");

// Install the GIT hooks
gulp.task("hooks", function() {
    return gulp.src([ ".git-hooks/pre-commit", ".git-hooks/post-merge" ])
    .pipe(symlink([ ".git/hooks/pre-commit", ".git/hooks/post-merge" ], {
        relative: true,
        force: true
    }));
});

Now, we need to run the task on all the machines so that the hooks are set up. We can automate that too. git is not the only one which provides hooks, npm does too. We'll add a npm postinstall hook which sets up the git hooks (so many hooks, eh?). Let's add an entry named postinstall under scripts in our package.json file, which will add the npm hook.

{
  ...
  "scripts": {
    "postinstall": "gulp hooks"
  }
  ...
}

Now we're all done and ready to go!

I've tried to keep the tutorial simple and generic. Try it out and tweak things as you need. Though the tutorial uses gulp, it's easy to modify it to use grunt instead. Also, we're using bash to write the scripts. But you could use another scripting language if you wish. Let me know what you think in comments.