UNPKG

post-commit

Version:

Automatically install post-commit hooks for your npm modules.

310 lines (263 loc) 8.58 kB
'use strict'; var spawn = require('cross-spawn') , which = require('which') , path = require('path') , util = require('util') , tty = require('tty'); /** * Representation of a hook runner. * * @constructor * @param {Function} fn Function to be called when we want to exit * @param {Object} options Optional configuration, primarily used for testing. * @api public */ function Hook(fn, options) { if (!this) return new Hook(fn, options); options = options || {}; this.options = options; // Used for testing only. Ignore this. Don't touch. this.config = {}; // post-commit configuration from the `package.json`. this.json = {}; // Actual content of the `package.json`. this.npm = ''; // The location of the `npm` binary. this.git = ''; // The location of the `git` binary. this.root = ''; // The root location of the .git folder. this.exit = fn; // Exit function. this.commit = ''; // The commit get with `git log --oneline -1 head` this.commitMessage = ''; // The commit msg get with `git log --oneline -1 head` this.commitId = ''; // The commit md5 id get with `git log --oneline -1 head` this.initialize(); } /** * Boolean indicating if we're allowed to output progress information into the * terminal. * * @type {Boolean} * @public */ Object.defineProperty(Hook.prototype, 'silent', { get: function silent() { return !!this.config.silent; } }); /** * Boolean indicating if we're allowed and capable of outputting colors into the * terminal. * * @type {Boolean} * @public */ Object.defineProperty(Hook.prototype, 'colors', { get: function colors() { return this.config.colors !== false && tty.isatty(process.stdout.fd); } }); /** * Execute a binary. * * @param {String} bin Binary that needs to be executed * @param {Array} args Arguments for the binary * @returns {Object} * @api private */ Hook.prototype.exec = function exec(bin, args) { return spawn.sync(bin, args, { stdio: 'pipe' }); }; /** * Parse the package.json so we can create an normalize it's contents to * a usable configuration structure. * * @api private */ Hook.prototype.parse = function parse() { var post = this.json['post-commit'] || this.json.postcommit , config = !Array.isArray(post) && 'object' === typeof post ? post : {}; ['silent', 'colors'].forEach(function each(flag) { var value; if (flag in config) value = config[flag]; else if ('postcommit.' + flag in this.json) value = this.json['postcommit.' + flag]; else if ('post-commit.' + flag in this.json) value = this.json['post-commit.' + flag]; else return; config[flag] = value; }, this); // // The scripts we need to run can be set under the `run` property. // config.run = config.run || post; if ('string' === typeof config.run) config.run = config.run.split(/[, ]+/); this.config = config; }; /** * Write messages to the terminal, for feedback purposes. * * @param {Array} lines The messages that need to be written. * @param {Number} exit Exit code for the process.exit. * @api public */ Hook.prototype.log = function log(lines, exit) { if (!Array.isArray(lines)) lines = lines.split('\n'); if ('number' !== typeof exit) exit = 1; var prefix = this.colors ? '\u001b[38;5;166mpost-commit:\u001b[39;49m ' : 'post-commit: '; lines.push(''); // Whitespace at the end of the log. lines.unshift(''); // Whitespace at the beginning. lines = lines.map(function map(line) { return prefix + line; }); if (!this.silent) lines.forEach(function output(line) { if (exit) console.error(line); else console.log(line); }); this.exit(exit, lines); return exit === 0; }; /** * Initialize all the values of the constructor to see if we can run as an * pos-commit hook. * * @api private */ Hook.prototype.initialize = function initialize() { ['git', 'npm'].forEach(function each(binary) { try { this[binary] = which.sync(binary); } catch (e) { } }, this); // // in GUI clients node and npm are not in the PATH so get node binary PATH, // add it to the PATH list and try again. // if (!this.npm) { try { process.env.PATH += path.delimiter + path.dirname(process.env._); this.npm = which.sync('npm'); } catch (e) { return this.log(this.format(Hook.log.binary, 'npm'), 0); } } // // Also bail out if we cannot find the git binary. // if (!this.git) return this.log(this.format(Hook.log.binary, 'git'), 0); this.commit = this.exec(this.git, ['log', '--oneline', '-1', 'head']); if (this.commit.code) return this.log(Hook.log.commit, 0); this.commit = this.commit.stdout.toString().trim(); var commitTmp = this.commit.match(/^(.{7}) (.*)$/); if (!commitTmp) { this.commitMessage = this.commit; } else { this.commitId = commitTmp[1]; this.commitMessage = commitTmp[2]; } this.root = this.exec(this.git, ['rev-parse', '--show-toplevel']); if (this.root.code) return this.log(Hook.log.root, 0); this.root = this.root.stdout.toString().trim(); try { this.json = require(path.join(this.root, 'package.json')); this.parse(); } catch (e) { return this.log(this.format(Hook.log.json, e.message), 0); } if (!this.config.run) return this.log(Hook.log.run, 0); }; /** * Run the specified hooks. * * @api public */ Hook.prototype.run = function runner() { var hooked = this; (function again(scripts) { if (!scripts.length) return hooked.exit(0); var script = scripts.shift(); // // There's a reason on why we're using an async `spawn` here instead of the // `shelljs.exec`. The sync `exec` is a hack that writes writes a file to // disk and they poll with sync fs calls to see for results. The problem is // that the way they capture the output which us using input redirection and // this doesn't have the required `isAtty` information that libraries use to // output colors resulting in script output that doesn't have any color. // spawn(hooked.npm, ['run', script, '--silent', 'commit=' + hooked.commit, 'commit-message=' + hooked.commitMessage, 'commit-id' + hooked.commitId,], { env: process.env, cwd: hooked.root, stdio: [0, 1, 2] }).once('close', function closed(code) { if (code) return hooked.log(hooked.format(Hook.log.failure, script, code)); again(scripts); }); })(hooked.config.run.slice(0)); }; /** * Expose some of our internal tools so plugins can also re-use them for their * own processing. * * @type {Function} * @public */ Hook.prototype.format = util.format; /** * The various of error and status messages that we can output. * * @type {Object} * @private */ Hook.log = { binary: [ 'Failed to locate the `%s` binary, make sure it\'s installed in your $PATH.', 'Skipping the post-commit hook.' ].join('\n'), commit: [ 'Failed to get the latest commit with `git log --oneline -1 head`', 'Skipping the post-commit hook.' ].join('\n'), status: [ 'Failed to retrieve the `git status` from the project.', 'Skipping the post-commit hook.' ].join('\n'), root: [ 'Failed to find the root of this git repository, cannot locate the `package.json`.', 'Skipping the post-commit hook.' ].join('\n'), empty: [ 'No commit detected.', 'Skipping the post-commit hook.' ].join('\n'), json: [ 'Received an error while parsing or locating the `package.json` file:', '', ' %s', '', 'Skipping the post-commit hook.' ].join('\n'), run: [ 'We have nothing post-commit hooks to run. Either you\'re missing the `scripts`', 'in your `package.json` or have configured post-commit to run nothing.', 'Skipping the post-commit hook.' ].join('\n'), failure: [ 'We\'ve failed to pass the specified git post-commit hooks as the `%s`', 'hook returned an exit code (%d). If you\'re feeling adventurous you can', 'skip the git post-commit hooks by adding the following flags to your commit:', '', ' git commit -n (or --no-verify)', '', 'This is ill-advised since the commit is broken.' ].join('\n') }; // // Expose the Hook instance so we can use it for testing purposes. // module.exports = Hook; // // Run directly if we're required executed directly through the CLI // if (module !== require.main) return; var hook = new Hook(function cli(code) { process.exit(code); }); hook.run();