diff --git a/.gitignore b/.gitignore index 3c3629e..8c24345 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ node_modules +npm-debug.log +coverage +.tern-port diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..064309e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +sudo: false +language: node_js +node_js: + - "0.12" + - "0.10" + - "4" + - "iojs" +script: + - "npm run test-travis" +after_script: + - "npm install coveralls@2.11.x && cat coverage/lcov.info | coveralls" +matrix: + fast_finish: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 3649b3b..8ebb32d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,45 @@ +## 1.0.2 +- Check `/usr/local/bin/node` if we cannot find the binaries in the PATH. + +## 1.0.1 +- Corrected the `hook` file so it doesn't attempt to run **your** index.js but + **ours** instead. + +## 1.0 +- Create symlinks instead of a copying the hook file so we can depend on + modules. +- More readable output messages. +- Lookup git and npm using `which`. +- Allow nodejs, node and iojs to call the the hook. +- Refactored the way options can be passed in to pre-commit, we're now allowing + objects. +- The refactor made it possible to test most of the internals so we now have + 90%+ coverage. +- And the list goes on. + +## 0.0.9 +- Added missing uninstall hook to remove and restore old scripts. + +## 0.0.8 +- Added support for installing custom commit templates using `pre-commit.commit-template` + +## 0.0.7 +- Fixes regression introduced in 0.0.6 + +## 0.0.6 +- Also silence `npm` output when the silent flag has been given. + +## 0.0.5 +- Allow silencing of the pre-commit output by setting a `precommit.silent: true` + in your `package.json` + ## 0.0.4 - Added a better error message when you fucked up your `package.json`. - Only run tests if there are changes. - Improved output formatting. ## 0.0.3 -- Added compatiblity for Node.js 0.6 by falling back to path.existsSync. +- Added compatibility for Node.js 0.6 by falling back to path.existsSync. ## 0.0.2 - Fixed a typo in the output, see #1. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b68d272 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Arnout Kazemier, Martijn Swaagman, the Contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md index 822d64a..16f6b2e 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,24 @@ # pre-commit -A simple `pre-commit` hook installer for `git`. This will ensure that your -test suite passes before you can commit your changes. In addition to running -your `npm test` it also has the option to run custom scripts that you have -specified in your `package.json`. +[![Version npm][version]](http://browsenpm.org/package/pre-commit)[![Build Status][build]](https://travis-ci.org/observing/pre-commit)[![Dependencies][david]](https://david-dm.org/observing/pre-commit)[![Coverage Status][cover]](https://coveralls.io/r/observing/pre-commit?branch=master) + +[version]: http://img.shields.io/npm/v/pre-commit.svg?style=flat-square +[build]: http://img.shields.io/travis/observing/pre-commit/master.svg?style=flat-square +[david]: https://img.shields.io/david/observing/pre-commit.svg?style=flat-square +[cover]: http://img.shields.io/coveralls/observing/pre-commit/master.svg?style=flat-square + +**pre-commit** is a pre-commit hook installer for `git`. It will ensure that +your `npm test` (or other specified scripts) passes before you can commit your +changes. This all conveniently configured in your `package.json`. + +But don't worry, you can still force a commit by telling `git` to skip the +`pre-commit` hooks by simply committing using `--no-verify`. ### Installation -It's advised to install this module as `devDependency` in your `package.json` -file so it doesn't get installed on production servers. Run: +It's advised to install the **pre-commit** module as a `devDependencies` in your +`package.json` as you only need this for development purposes. To install the +module simply run: ``` npm install --save-dev pre-commit @@ -16,20 +26,26 @@ npm install --save-dev pre-commit To install it as `devDependency`. When this module is installed it will override the existing `pre-commit` file in your `.git/hooks` folder. Existing -`pre-commit` hooks will be backed up. +`pre-commit` hooks will be backed up as `pre-commit.old` in the same repository. ### Configuration -`pre-commit` will try to run your `npm test` command by default. It does this by -running `npm run test` in the root of your git repository. It will only run that -command if it's not the default values that are entered when you issue an `npm -init`. +`pre-commit` will try to run your `npm test` command in the root of the git +repository by default unless it's the default value that is set by the `npm +init` script. But `pre-commit` is not limited to just running your `npm test`'s during the commit hook. It's also capable of running every other script that you've -specified in your `package.json` "scripts" field. The only thing you need to do -is add a `pre-commit` array to your `package.json` that specifies which scripts -you want to have ran and in which order: +specified in your `package.json` "scripts" field. So before people commit you +could ensure that: + +- You have 100% coverage +- All styling passes. +- JSHint passes. +- Contribution licenses signed etc. + +The only thing you need to do is add a `pre-commit` array to your `package.json` +that specifies which scripts you want to have ran and in which order: ```js { @@ -38,32 +54,73 @@ you want to have ran and in which order: "description": "ERROR: No README.md file found!", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "echo \"Error: I SHOULD FAIL LOLOLOLOLOL \" && exit 1", "foo": "echo \"fooo\" && exit 0", "bar": "echo \"bar\" && exit 0" }, - "repository": { - "type": "git", - "url": "https://gist.github.com/437464d0899504fb6b7b.git" - }, "pre-commit": [ "foo", "bar", "test" - ], - "author": "", - "license": "BSD", - "gitHead": "6637d0771c3a89c4a60be087859dee5130f7a104" + ] } ``` In the example above, it will first run: `npm run foo` then `npm run bar` and finally `npm run test` which will make the commit fail as it returns the error -code `1`. +code `1`. If you prefer strings over arrays or `precommit` without a middle +dash, that also works: + +```js +{ + "precommit": "foo, bar, test" + "pre-commit": "foo, bar, test" + "pre-commit": ["foo", "bar", "test"] + "precommit": ["foo", "bar", "test"], + "precommit": { + "run": "foo, bar, test", + }, + "pre-commit": { + "run": ["foo", "bar", "test"], + }, + "precommit": { + "run": ["foo", "bar", "test"], + }, + "pre-commit": { + "run": "foo, bar, test", + } +} +``` + +The examples above are all the same. In addition to configuring which scripts +should be ran you can also configure the following options: + +- **silent** Don't output the prefixed `pre-commit:` messages when things fail + or when we have nothing to run. Should be a boolean. +- **colors** Don't output colors when we write messages. Should be a boolean. +- **template** Path to a file who's content should be used as template for the + git commit body. + +These options can either be added in the `pre-commit`/`precommit` object as keys +or as `"pre-commit.{key}` key properties in the `package.json`: + +```js +{ + "precommit.silent": true, + "pre-commit": { + "silent": true + } +} +``` + +It's all the same. Different styles so use what matches your project. To learn +more about the scripts, please read the official `npm` documentation: + +https://docs.npmjs.com/misc/scripts -To learn more about the scripts, please read the official `npm` documentation: +And to learn more about git hooks read: -https://npmjs.org/doc/scripts.html +http://githooks.com ### License diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..a1f17e4 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,27 @@ +environment: + matrix: + - nodejs_version: "0.10" + - nodejs_version: "0.12" + - nodejs_version: "1.0" + +matrix: + fast_finish: true + allow_failures: + - platform: x86 + - platform: x64 + +platform: + - x86 + - x64 + +install: + - ps: Install-Product node $env:nodejs_version $env:platform + - npm install -g npm@1.4.x + - npm install + +test_script: + - node --version + - npm --version + - npm test + +build: off diff --git a/hook b/hook index 8eedfed..1bf23b8 100755 --- a/hook +++ b/hook @@ -1,196 +1,50 @@ -#!/usr/bin/env node - -'use strict'; - -var child = require('child_process'); - -// -// Get the root of the repository. -// -child.exec('git status --porcelain', function changes(err, status) { - if (err) { - console.error('pre-commit: Failed to find git root. Cannot run the tests.'); - return process.exit(1); - } - - child.exec('git rev-parse --show-toplevel', run.bind(null, status)); -}); - -/** - * You've failed on some of the scripts, output how much you've sucked today. - * - * @param {Error} err The actual error. - * @api private - */ -function failure(err) { - console.error(''); - console.error('pre-commit: You\'ve failed to pass all the hooks.'); - console.error('pre-commit:'); - - if (err.ran) { - console.error('pre-commit: The "npm run '+ err.ran +'" script failed.'); - } else { - var stack = err.stack.split('\n') - console.error('pre-commit: An Error was thrown: '+ stack.shift()); - console.error('pre-commit:'); - stack.forEach(function trace(line) { - console.error('pre-commit: '+ line.trim()); - }); - } - console.error('pre-commit:'); - console.error('pre-commit: You can skip the git pre-commit hook by running:'); - console.error('pre-commit:'); - console.error('pre-commit: git commit -n (--no-verify)'); - console.error('pre-commit:'); - console.error('pre-commit: But this is not adviced as your tests are obviously failing.'); - console.error(''); - process.exit(1); +#!/usr/bin/env bash + +HAS_NODE=`which node 2> /dev/null || which nodejs 2> /dev/null || which iojs 2> /dev/null` + +# +# There are some issues with Source Tree because paths are not set correctly for +# the given environment. Sourcing the bash_profile seems to resolve this for bash users, +# sourcing the zshrc for zshell users. +# +# https://answers.atlassian.com/questions/140339/sourcetree-hook-failing-because-paths-don-t-seem-to-be-set-correctly +# +function source_home_file { + file="$HOME/$1" + [[ -f "${file}" ]] && source "${file}" } -/** - * Run the set pre-commit hooks. - * - * @param {Error} err The error that happend while executing the command. - * @param {Error} output The output of rev-parse. - * @api private - */ -function run(status, err, output) { - if (err) { - console.error(''); - console.error('pre-commit: Failed to find git root. Cannot run the tests.'); - console.error(''); - return process.exit(1); - } - - // - // Check if there are scripts specified that we need to run. - // - var root = output.trim() - , run = [] - , hasPreCommit = false - , silent - , pkg - , commit_template - , commit_template_cmd; - - // - // Bail-out when we failed to parse the package.json, there is probably a some - // funcky chars in there. - // - try { pkg = require(root +'/package.json'); } - catch (e) { return failure(e); } - - silent = pkg['pre-commit.silent'] || false; - - if (!status.trim().length) { - if (!silent) { - console.log(''); - console.log('pre-commit: No changes detected, bailing out.'); - console.log(''); - } - return; - } - - if (!pkg.scripts) { - if (!silent) { - console.log(''); - console.log('pre-commit: No scripts detected in the package.json, bailing out.'); - console.log(''); - } - return; - } - - // - // If there's a `pre-commit` property in the package.json we should use that - // array. - // - if (pkg['pre-commit'] && Array.isArray(pkg['pre-commit'])) { - hasPreCommit = true; - run = pkg['pre-commit']; - } - //configure commit.template in git if we are asked to do so - commit_template = pkg['pre-commit.commit-template']; - if (commit_template) { - commit_template_cmd = 'git config commit.template "' + commit_template + '"'; - child.exec(commit_template_cmd, [], function(error, stdout, stderr) { - if (error) { //it is better to write this even if we are 'silent' - stderr.write('pre-commit: ' + commit_template_cmd + ' failed\n'); - } - }); - } - - // - // If we don't have any run processes to run try to see if there's a `test` - // property which we should run instead. But we should check if it's not the - // default value that `npm` adds when your run the `npm init` command. - // - if ( - !hasPreCommit - && !run.length - && pkg.scripts.test - && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1' - ) { - run.push('test'); - } - - // - // Bailout if we don't have anything to run. - // - if (!run.length) { - if (!silent) { - console.log(''); - console.log('pre-commit: Nothing to run. Bailing out.'); - console.log(''); - } - return; - } - - // - // Behold, a lazy man's async flow control library; - // - (function runner(done) { - (function next(err, task) { - // - // Bailout when we received an error. This will make sure that we don't - // run the rest of the tasks. - // - if (err) { - err = new Error(err.message); - err.ran = task; - return done(err); - } - - // Check if we have tasks to be executed or if we are complete. - task = run.shift(); - if (!task) return done(); - - var args = ['run', task]; - - if (silent) { - args.push('--silent'); - } - - var npm = child.spawn('npm', args, { - cwd: root, // Make sure that we spawn it in the root of repo. - env: process.env, // Give them the same ENV variables. - stdio: [0, 1, 2] // Pipe all the things. - }); - - // - // Check the close code to see if we passed or failed. - // - npm.on('close', function close(code) { - if (code !== 0) return next(new Error(task +' closed with code '+ code), task); - - next(undefined, task); - }); - })(); - })(function ready(err) { - if (err) return failure(err); - - // - // Congratulation young padawan, all hooks passed. - // - process.exit(0); - }); -} +if [[ -z "$HAS_NODE" ]]; then + source_home_file ".bash_profile" || source_home_file ".zshrc" || source_home_file ".bashrc" || true +fi + +NODE=`which node 2> /dev/null` +NODEJS=`which nodejs 2> /dev/null` +IOJS=`which iojs 2> /dev/null` +LOCAL="/usr/local/bin/node" +BINARY= + +# +# Figure out which binary we need to use for our script execution. +# +if [[ -n "$NODE" ]]; then + BINARY="$NODE" +elif [[ -n "$NODEJS" ]]; then + BINARY="$NODEJS" +elif [[ -n "$IOJS" ]]; then + BINARY="$IOJS" +elif [[ -x "$LOCAL" ]]; then + BINARY="$LOCAL" +fi + +# +# Add --dry-run cli flag support so we can execute this hook without side effects +# and see if it works in the current environment +# +if [[ $* == *--dry-run* ]]; then + if [[ -z "$BINARY" ]]; then + exit 1 + fi +else + "$BINARY" "$("$BINARY" -e "console.log(require.resolve('pre-commit'))")" +fi diff --git a/index.js b/index.js new file mode 100644 index 0000000..89f5a9d --- /dev/null +++ b/index.js @@ -0,0 +1,315 @@ +'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 = {}; // pre-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.status = ''; // Contents of the `git status`. + this.exit = fn; // Exit function. + + 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 pre = this.json['pre-commit'] || this.json.precommit + , config = !Array.isArray(pre) && 'object' === typeof pre ? pre : {}; + + ['silent', 'colors', 'template'].forEach(function each(flag) { + var value; + + if (flag in config) value = config[flag]; + else if ('precommit.'+ flag in this.json) value = this.json['precommit.'+ flag]; + else if ('pre-commit.'+ flag in this.json) value = this.json['pre-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 || pre; + + if ('string' === typeof config.run) config.run = config.run.split(/[, ]+/); + if ( + !Array.isArray(config.run) + && this.json.scripts + && this.json.scripts.test + && this.json.scripts.test !== 'echo "Error: no test specified" && exit 1' + ) { + config.run = ['test']; + } + + 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;166mpre-commit:\u001b[39;49m ' + : 'pre-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 + * pre-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.root = this.exec(this.git, ['rev-parse', '--show-toplevel']); + this.status = this.exec(this.git, ['status', '--porcelain']); + + if (this.status.code) return this.log(Hook.log.status, 0); + if (this.root.code) return this.log(Hook.log.root, 0); + + this.status = this.status.stdout.toString().trim(); + 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); } + + // + // We can only check for changes after we've parsed the package.json as it + // contains information if we need to suppress the empty message or not. + // + if (!this.status.length && !this.options.ignorestatus) { + return this.log(Hook.log.empty, 0); + } + + // + // If we have a git template we should configure it before checking for + // scripts so it will still be applied even if we don't have anything to + // execute. + // + if (this.config.template) { + this.exec(this.git, ['config', 'commit.template', this.config.template]); + } + + 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'], { + 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 pre-commit hook.' + ].join('\n'), + + status: [ + 'Failed to retrieve the `git status` from the project.', + 'Skipping the pre-commit hook.' + ].join('\n'), + + root: [ + 'Failed to find the root of this git repository, cannot locate the `package.json`.', + 'Skipping the pre-commit hook.' + ].join('\n'), + + empty: [ + 'No changes detected.', + 'Skipping the pre-commit hook.' + ].join('\n'), + + json: [ + 'Received an error while parsing or locating the `package.json` file:', + '', + ' %s', + '', + 'Skipping the pre-commit hook.' + ].join('\n'), + + run: [ + 'We have nothing pre-commit hooks to run. Either you\'re missing the `scripts`', + 'in your `package.json` or have configured pre-commit to run nothing.', + 'Skipping the pre-commit hook.' + ].join('\n'), + + failure: [ + 'We\'ve failed to pass the specified git pre-commit hooks as the `%s`', + 'hook returned an exit code (%d). If you\'re feeling adventurous you can', + 'skip the git pre-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(); diff --git a/install.js b/install.js index 19414df..e24fc0d 100644 --- a/install.js +++ b/install.js @@ -1,59 +1,130 @@ 'use strict'; -var path = require('path') - , fs = require('fs'); - // -// Compatibility with older node.js. +// Compatibility with older node.js as path.exists got moved to `fs`. // -var existsSync = fs.existsSync || path.existsSync; +var fs = require('fs') + , path = require('path') + , os = require('os') + , hook = path.join(__dirname, 'hook') + , root = path.resolve(__dirname, '..', '..') + , exists = fs.existsSync || path.existsSync; // -// Our own pre-commit hook runner. +// Gather the location of the possible hidden .git directory, the hooks +// directory which contains all git hooks and the absolute location of the +// `pre-commit` file. The path needs to be absolute in order for the symlinking +// to work correctly. // -var hook = fs.readFileSync('./hook'); -// -// The root of repository. -// -var root = path.resolve(__dirname, '../..'); +var git = getGitFolderPath(root); -// -// The location .git and it's hooks -// -var git = path.resolve(root, '.git') - , hooks = path.resolve(git, 'hooks') - , precommit = path.resolve(hooks, 'pre-commit'); +// Function to recursively finding .git folder +function getGitFolderPath(currentPath) { + var git = path.resolve(currentPath, '.git') + + if (!exists(git) || !fs.lstatSync(git).isDirectory()) { + console.log('pre-commit:'); + console.log('pre-commit: Not found .git folder in', git); + + var newPath = path.resolve(currentPath, '..'); + + // Stop if we on top folder + if (currentPath === newPath) { + return null; + } + + return getGitFolderPath(newPath); + } + + console.log('pre-commit:'); + console.log('pre-commit: Found .git folder in', git); + return git; +} // -// Check if we are in a git repository so we can bail out early when this is not -// the case. +// Resolve git directory for submodules // -if (!existsSync(git) || !fs.lstatSync(git).isDirectory()) return; +if (exists(git) && fs.lstatSync(git).isFile()) { + var gitinfo = fs.readFileSync(git).toString() + , gitdirmatch = /gitdir: (.+)/.exec(gitinfo) + , gitdir = gitdirmatch.length == 2 ? gitdirmatch[1] : null; + + if (gitdir !== null) { + git = path.resolve(root, gitdir); + hooks = path.resolve(git, 'hooks'); + precommit = path.resolve(hooks, 'pre-commit'); + } +} // -// Create a hooks directory if it's missing. +// Bail out if we don't have an `.git` directory as the hooks will not get +// triggered. If we do have directory create a hooks folder if it doesn't exist. // -if (!existsSync(hooks)) fs.mkdirSync(hooks); +if (!git) { + console.log('pre-commit:'); + console.log('pre-commit: Not found any .git folder for installing pre-commit hook'); + return; +} + +var hooks = path.resolve(git, 'hooks') + , precommit = path.resolve(hooks, 'pre-commit'); + +if (!exists(hooks)) fs.mkdirSync(hooks); // // If there's an existing `pre-commit` hook we want to back it up instead of -// overriding it and losing it completely +// overriding it and losing it completely as it might contain something +// important. // -if ( - existsSync(precommit) - && fs.readFileSync(precommit).toString('utf8') !== hook.toString('utf8') -) { - console.log(''); +if (exists(precommit) && !fs.lstatSync(precommit).isSymbolicLink()) { + console.log('pre-commit:'); console.log('pre-commit: Detected an existing git pre-commit hook'); fs.writeFileSync(precommit +'.old', fs.readFileSync(precommit)); console.log('pre-commit: Old pre-commit hook backuped to pre-commit.old'); - console.log(''); + console.log('pre-commit:'); } // -// Everything is ready for the installation of the pre-commit hook. Write it and -// make it executable. +// We cannot create a symlink over an existing file so make sure it's gone and +// finish the installation process. // -fs.writeFileSync(precommit, hook); -fs.chmodSync(precommit, '755'); +try { fs.unlinkSync(precommit); } +catch (e) {} + +// Create generic precommit hook that launches this modules hook (as well +// as stashing - unstashing the unstaged changes) +// TODO: we could keep launching the old pre-commit scripts +var hookRelativeUnixPath = hook.replace(root, '.'); + +if(os.platform() === 'win32') { + hookRelativeUnixPath = hookRelativeUnixPath.replace(/[\\\/]+/g, '/'); +} + +var precommitContent = '#!/usr/bin/env bash' + os.EOL + + hookRelativeUnixPath + os.EOL + + 'RESULT=$?' + os.EOL + + '[ $RESULT -ne 0 ] && exit 1' + os.EOL + + 'exit 0' + os.EOL; + +// +// It could be that we do not have rights to this folder which could cause the +// installation of this module to completely fail. We should just output the +// error instead destroying the whole npm install process. +// +try { fs.writeFileSync(precommit, precommitContent); } +catch (e) { + console.error('pre-commit:'); + console.error('pre-commit: Failed to create the hook file in your .git/hooks folder because:'); + console.error('pre-commit: '+ e.message); + console.error('pre-commit: The hook was not installed.'); + console.error('pre-commit:'); +} + +try { fs.chmodSync(precommit, '777'); } +catch (e) { + console.error('pre-commit:'); + console.error('pre-commit: chmod 0777 the pre-commit file in your .git/hooks folder because:'); + console.error('pre-commit: '+ e.message); + console.error('pre-commit:'); +} diff --git a/package.json b/package.json index 75c995f..dc1ff70 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,43 @@ { "name": "pre-commit", - "version": "0.0.7", + "version": "1.2.2", "description": "Automatically install pre-commit hooks for your npm modules.", "main": "index.js", "scripts": { - "install": "node install" + "coverage": "istanbul cover ./node_modules/.bin/_mocha -- test.js", + "example-fail": "echo \"This is the example hook, I exit with 1\" && exit 1", + "example-pass": "echo \"This is the example hook, I exit with 0\" && exit 0", + "install": "node install.js", + "test": "mocha test.js", + "test-travis": "istanbul cover node_modules/.bin/_mocha --report lcovonly -- test.js", + "uninstall": "node uninstall.js" }, "repository": { "type": "git", "url": "git://github.com/observing/pre-commit.git" }, "keywords": [ + "git", + "hooks", + "npm", "pre-commit", "precommit", + "run", "test", - "npm", - "run" + "development" ], "author": "Arnout Kazemier ", + "homepage": "https://github.com/observing/pre-commit", "license": "MIT", + "dependencies": { + "cross-spawn": "^5.0.1", + "spawn-sync": "^1.0.15", + "which": "1.2.x" + }, "devDependencies": { + "assume": "~1.5.0", + "istanbul": "0.4.x", + "mocha": "~3.3.0", "pre-commit": "git://github.com/observing/pre-commit.git" } } diff --git a/test.js b/test.js new file mode 100644 index 0000000..829e17f --- /dev/null +++ b/test.js @@ -0,0 +1,257 @@ +/* istanbul ignore next */ +describe('pre-commit', function () { + 'use strict'; + + var assume = require('assume') + , Hook = require('./'); + + it('is exported as a function', function () { + assume(Hook).is.a('function'); + }); + + it('can be initialized without a `new` keyword', function () { + var hook = Hook(function () {}, { + ignorestatus: true + }); + + assume(hook).is.instanceOf(Hook); + assume(hook.parse).is.a('function'); + }); + + describe('#parse', function () { + var hook; + + beforeEach(function () { + hook = new Hook(function () {}, { + ignorestatus: true + }); + }); + + it('extracts configuration values from precommit.', function () { + hook.json = { + 'precommit.silent': true + }; + + assume(hook.silent).is.false(); + + hook.parse(); + + assume(hook.config.silent).is.true(); + assume(hook.silent).is.true(); + }); + + it('extracts configuration values from pre-commit.', function () { + hook.json = { + 'pre-commit.silent': true, + 'pre-commit.colors': false + }; + + assume(hook.silent).is.false(); + assume(hook.colors).is.true(); + + hook.parse(); + + assume(hook.config.silent).is.true(); + assume(hook.silent).is.true(); + assume(hook.colors).is.false(); + }); + + it('normalizes the `pre-commit` to an array', function () { + hook.json = { + 'pre-commit': 'test, cows, moo' + }; + + hook.parse(); + + assume(hook.config.run).is.length(3); + assume(hook.config.run).contains('test'); + assume(hook.config.run).contains('cows'); + assume(hook.config.run).contains('moo'); + }); + + it('normalizes the `precommit` to an array', function () { + hook.json = { + 'precommit': 'test, cows, moo' + }; + + hook.parse(); + + assume(hook.config.run).is.length(3); + assume(hook.config.run).contains('test'); + assume(hook.config.run).contains('cows'); + assume(hook.config.run).contains('moo'); + }); + + it('allows `pre-commit` object based syntax', function () { + hook.json = { + 'pre-commit': { + run: 'test scripts go here', + silent: true, + colors: false + } + }; + + hook.parse(); + + assume(hook.config.run).is.length(4); + assume(hook.config.run).contains('test'); + assume(hook.config.run).contains('scripts'); + assume(hook.config.run).contains('go'); + assume(hook.config.run).contains('here'); + assume(hook.silent).is.true(); + assume(hook.colors).is.false(); + }); + + it('defaults to `test` if nothing is specified', function () { + hook.json = { + scripts: { + test: 'mocha test.js' + } + }; + + hook.parse(); + assume(hook.config.run).deep.equals(['test']); + }); + + it('ignores the default npm.script.test placeholder', function () { + hook.json = { + scripts: { + test: 'echo "Error: no test specified" && exit 1' + } + }; + + hook.parse(); + assume(hook.config.run).has.length(0); + }); + }); + + describe('#log', function () { + it('prefixes the logs with `pre-commit`', function (next) { + var hook = new Hook(function (code, lines) { + assume(code).equals(1); + assume(lines).is.a('array'); + + assume(lines[0]).includes('pre-commit'); + assume(lines[1]).includes('pre-commit'); + assume(lines[1]).includes('foo'); + assume(lines).has.length(3); + + // color prefix check + lines.forEach(function (line) { + assume(line).contains('\u001b'); + }); + + next(); + }, { ignorestatus: true }); + + hook.config.silent = true; + hook.log(['foo']); + }); + + it('allows for a custom error code', function (next) { + var hook = new Hook(function (code, lines) { + assume(code).equals(0); + + next(); + }, { ignorestatus: true }); + + hook.config.silent = true; + hook.log(['foo'], 0); + }); + + it('allows strings to be split \\n', function (next) { + var hook = new Hook(function (code, lines) { + assume(code).equals(0); + + assume(lines).has.length(4); + assume(lines[1]).contains('foo'); + assume(lines[2]).contains('bar'); + + next(); + }, { ignorestatus: true }); + + hook.config.silent = true; + hook.log('foo\nbar', 0); + }); + + it('does not output colors when configured to do so', function (next) { + var hook = new Hook(function (code, lines) { + assume(code).equals(0); + + lines.forEach(function (line) { + assume(line).does.not.contain('\u001b'); + }); + + next(); + }, { ignorestatus: true }); + + hook.config.silent = true; + hook.config.colors = false; + + hook.log('foo\nbar', 0); + }); + + it('output lines to stderr if error code 1', function (next) { + var err = console.error; + next = assume.plan(4, next); + + var hook = new Hook(function (code, lines) { + console.error = err; + next(); + }, { ignorestatus: true }); + + console.error = function (line) { + assume(line).contains('pre-commit: '); + }; + + hook.config.colors = false; + hook.log('foo\nbar', 1); + }); + + it('output lines to stdout if error code 0', function (next) { + var log = console.log; + next = assume.plan(4, next); + + var hook = new Hook(function (code, lines) { + console.log = log; + next(); + }, { ignorestatus: true }); + + console.log = function (line) { + assume(line).contains('pre-commit: '); + }; + + hook.config.colors = false; + hook.log('foo\nbar', 0); + }); + }); + + describe('#run', function () { + it('runs the specified scripts and exit with 0 on no error', function (next) { + var hook = new Hook(function (code, lines) { + assume(code).equals(0); + assume(lines).is.undefined(); + + next(); + }, { ignorestatus: true }); + + hook.config.run = ['example-pass']; + hook.run(); + }); + + it('runs the specified test and exits with 1 on error', function (next) { + var hook = new Hook(function (code, lines) { + assume(code).equals(1); + + assume(lines).is.a('array'); + assume(lines[1]).contains('`example-fail`'); + assume(lines[2]).contains('code (1)'); + + next(); + }, { ignorestatus: true }); + + hook.config.run = ['example-fail']; + hook.run(); + }); + }); +}); diff --git a/uninstall.js b/uninstall.js new file mode 100644 index 0000000..7c47903 --- /dev/null +++ b/uninstall.js @@ -0,0 +1,42 @@ +'use strict'; + +var fs = require('fs') + , path = require('path') + , exists = fs.existsSync || path.existsSync + , root = path.resolve(__dirname, '..', '..') + , git = path.resolve(root, '.git'); + +// +// Resolve git directory for submodules +// +if (exists(git) && fs.lstatSync(git).isFile()) { + var gitinfo = fs.readFileSync(git).toString() + , gitdirmatch = /gitdir: (.+)/.exec(gitinfo) + , gitdir = gitdirmatch.length == 2 ? gitdirmatch[1] : null; + + if (gitdir !== null) { + git = path.resolve(root, gitdir); + } +} + +// +// Location of pre-commit hook, if it exists +// +var precommit = path.resolve(git, 'hooks', 'pre-commit'); + +// +// Bail out if we don't have pre-commit file, it might be removed manually. +// +if (!exists(precommit)) return; + +// +// If we don't have an old file, we should just remove the pre-commit hook. But +// if we do have an old precommit file we want to restore that. +// +if (!exists(precommit +'.old')) { + fs.unlinkSync(precommit); +} else { + fs.writeFileSync(precommit, fs.readFileSync(precommit +'.old')); + fs.chmodSync(precommit, '755'); + fs.unlinkSync(precommit +'.old'); +}