From 8ab4d7c91163f9a95274293a3ec9cd27be2117d2 Mon Sep 17 00:00:00 2001 From: Kevin Locke Date: Wed, 3 Feb 2016 17:39:05 -0800 Subject: [PATCH 1/2] Split out logOnly method from log Previously log would always call .exit(), which is intended to be fatal. In order to support non-fatal logging, split out a logOnly method which outputs the formatted lines but does not exit. This preserves the current API completely. Also add a call to .slice() if the lines argument is not a string so that the argument Array is not modified. This codepath is not currently used, but it is no longer a waiting surprise for future callers. Signed-off-by: Kevin Locke --- index.js | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index a20646d..3166e3e 100644 --- a/index.js +++ b/index.js @@ -112,13 +112,16 @@ Hook.prototype.parse = function parse() { /** * 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. + * @param {string|Array} lines The messages that need to be written. + * @param {?function(string)} dest Function to which lines will be written. + * (default: console.error) + * @returns {Array} Lines written to output. * @api public */ -Hook.prototype.log = function log(lines, exit) { +Hook.prototype.logOnly = function logOnly(lines, dest) { + dest = dest || console.error; if (!Array.isArray(lines)) lines = lines.split('\n'); - if ('number' !== typeof exit) exit = 1; + else lines = lines.slice(); var prefix = this.colors ? '\u001b[38;5;166mpre-commit:\u001b[39;49m ' @@ -132,11 +135,25 @@ Hook.prototype.log = function log(lines, exit) { }); if (!this.silent) lines.forEach(function output(line) { - if (exit) console.error(line); - else console.log(line); + // Note: This wrapper function is necessary to avoid extra args to output. + dest(line); }); - this.exit(exit, lines); + return lines; +}; + +/** + * Write messages to the terminal, for feedback purposes, then call exit. + * + * @param {string|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 ('number' !== typeof exit) exit = 1; + + var outputLines = this.logOnly(lines, exit ? console.error : console.log); + this.exit(exit, outputLines); return exit === 0; }; From 35737280ab3aad6f9cb9939313ef8c0a0357dab1 Mon Sep 17 00:00:00 2001 From: Kevin Locke Date: Wed, 3 Feb 2016 18:22:10 -0800 Subject: [PATCH 2/2] Add an option to stash changes before running hooks In order to ensure any pre-commit tests are run against the files as they will appear in the commit, this commit adds an option to run `git stash` to save any changes to the working tree before running the scripts, then restore any changes after the scripts finish. The save/restore procedure is based on Chris Torek's StackOverflow answer to a question asking how to do exactly this.[1] This implementation does not do a hard reset before restoring the stash by default, since it assumes that if scripts modify tracked files the changes should be kept. But it does provide this behavior, and `git clean`, as configurable options. It follows the convention requested in #4 by making the new stash option default to off. Although there are no known implementation issues (with the exception of the git bug noted in Torek's SO answer), current scripts may expect modified/untracked files to exist or modify untracked files in a way which prevents applying the stash, making default-on behavior backwards-incompatible. The tests are split into a separate file. Since each stash option is tested against a project repository and a clean/reset is done between each test, the tests are somewhat slow. By splitting the tests into a separate file, we can avoid running them by default. They can instead be run as test-stash, as part of test-all, and as part of test-travis. This commit is based off of the work of Christopher Hiller in #47, although the implementation differs significantly due to the use of Promises in place of async, which I found to be significantly clearer, more flexible, and they make the tests significantly more concise. Fixes: #4 1. https://stackoverflow.com/a/20480591 Signed-off-by: Christopher Hiller [kevin@kevinlocke.name: Reimplement using Promises and Torek's method] Signed-off-by: Kevin Locke --- .gitignore | 1 + README.md | 26 +++ index.js | 355 ++++++++++++++++++++++++++++++++++++--- package.json | 11 +- test-stash.js | 450 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 817 insertions(+), 26 deletions(-) create mode 100644 test-stash.js diff --git a/.gitignore b/.gitignore index 8c24345..fd3a5d4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules npm-debug.log coverage .tern-port +test-repo/ diff --git a/README.md b/README.md index 137671e..e1ea4a5 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,32 @@ should be ran you can also configure the following options: - **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. +- **stash** Run `git stash` to stash changes to the working directory prior to + run and restore changes after. This can be useful for checking files as they + will appear in the commit without uncommitted changes and files. It can also + cause rebuilds or reloads due to file changes for editors and watch scripts + and has some ([known issues](https://stackoverflow.com/a/20480591/503410)). + The value can be a boolean or an options object with the following properties: + - **clean** Run `git clean` before re-applying the stashed changes. + When combined with `includeUntracked`/`includeAll`, this would delete + any files files created by the scripts. In particular ones which would + prevent applying the stash if they exist in the working dir and stash. + - **includeAll** Include all files (both untracked and ignored) in stash + (and clean, if run). This is almost never what you want, since it will + stash `node_modules` among other things. + - **includeUntracked** Include untracked files in stash. This is useful to + exclude untracked js files from linting, but carries some risk of losing + [untracked files](http://article.gmane.org/gmane.comp.version-control.git/263385/) + or [directories which become + empty](http://thread.gmane.org/gmane.comp.version-control.git/254037) + or [files/directories changing ignore + status](http://thread.gmane.org/gmane.comp.version-control.git/282324) + or creating a stash which [requiring manual intervention to + apply](http://article.gmane.org/gmane.comp.version-control.git/285403). + Also, it takes extra effort to [view untracked files in the + stash](https://stackoverflow.com/a/22819771). + - **reset** Run `git reset --hard` before re-applying the stashed changes. + This would revert any changes to tracked files made by the scripts. 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`: diff --git a/index.js b/index.js index 3166e3e..67c2ec1 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,18 @@ 'use strict'; +// Polyfill Promise if running on Node < 0.11 +// Note: Must modify global Promise for buffered-spawn +if ('undefined' === typeof Promise) { + global.Promise = require('bluebird').Promise; +} + var spawn = require('cross-spawn') , which = require('which') , path = require('path') , util = require('util') - , tty = require('tty'); + , tty = require('tty') + , bufferedSpawn = require('buffered-spawn') + , promiseFinally = require('promise-finally').default; /** * Representation of a hook runner. @@ -26,6 +34,7 @@ function Hook(fn, options) { this.root = ''; // The root location of the .git folder. this.status = ''; // Contents of the `git status`. this.exit = fn; // Exit function. + this.stashed = false; // Whether any changes were stashed by pre-commit this.initialize(); } @@ -80,7 +89,7 @@ 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) { + ['silent', 'colors', 'template', 'stash'].forEach(function each(flag) { var value; if (flag in config) value = config[flag]; @@ -221,39 +230,325 @@ Hook.prototype.initialize = function initialize() { if (!this.config.run) return this.log(Hook.log.run, 0); }; +/** + * Do-nothing function for discarding Promise values. + * + * This function is purely for documentation purposes in preventing unwanted + * Promise values from leaking into an API. + */ +function discardResult(result) { +} + +/** + * Get the hash for a named object (branch, commit, ref, tree, etc.). + * + * @param {string} objName Name of object for which to get the hash. + * @returns {Promise} SHA1 hash of the named object. Null if name + * is not an object. Error if name could not be determined. + * @api private + */ +Hook.prototype._getGitHash = function getGitHash(objName) { + var hooked = this; + + return bufferedSpawn( + hooked.git, + ['rev-parse', '--quiet', '--verify', objName], + { + cwd: hooked.root, + stdio: ['ignore', 'pipe', 'ignore'] + } + ) + .then( + function (result) { + return result.stdout; + }, + function (err) { + if (err.status === 1) { + // git rev-parse exits with code 1 if name doesn't exist + return null; + } + + return Promise.reject(err); + } + ); +}; + +/** + * Stash changes to working directory. + * + * @returns {Promise} Promise which is resolved if stash completes successfully, + * rejected with an Error if stash can't be run or exits with non-0 exit code. + * @api private + */ +Hook.prototype._stash = function stash() { + var hooked = this; + var stashConfig = hooked.config.stash || {}; + + var args = [ + 'stash', + 'save', + '--quiet', + '--keep-index' + ]; + + if (stashConfig.includeAll) { + args.push('--all'); + } + if (stashConfig.includeUntracked) { + args.push('--include-untracked'); + } + + // name added to aid user in case of unstash failure + args.push('pre-commit stash'); + + return bufferedSpawn(hooked.git, args, { + cwd: hooked.root, + stdio: 'inherit' + }) + .then(discardResult); +}; + +/** + * Unstash changes ostensibly stashed by {@link Hook#_stash}. + * + * @returns {Promise} Promise which is resolved if stash completes successfully, + * rejected with an Error if stash can't be run or exits with non-0 exit code. + * @api private + */ +Hook.prototype._unstash = function unstash() { + var hooked = this; + + return bufferedSpawn(hooked.git, ['stash', 'pop', '--quiet'], { + cwd: hooked.root, + // Note: This prints 'Already up-to-date!' to stdout if there were no + // modified files (only untracked files). Although we could suppress it, + // the risk of missing a prompt or important output outweighs the benefit. + // Reported upstream in https://marc.info/?m=145457253905299 + stdio: 'inherit' + }) + .then(discardResult); +}; + +/** + * Clean files in preparation for unstash. + * + * @returns {Promise} Promise which is resolved if clean completes successfully, + * rejected with an Error if clean can't be run or exits with non-0 exit code. + * @api private + */ +Hook.prototype._clean = function clean() { + var hooked = this; + var stashConfig = hooked.config.stash || {}; + + var args = ['clean', '-d', '--force', '--quiet']; + if (stashConfig.includeAll) { + args.push('-x'); + } + return bufferedSpawn(hooked.git, args, { + cwd: hooked.root, + stdio: 'inherit' + }) + .then(discardResult); +}; + +/** + * Reset files in preparation for unstash. + * + * @returns {Promise} Promise which is resolved if reset completes successfully, + * rejected with an Error if reset can't be run or exits with non-0 exit code. + * @api private + */ +Hook.prototype._reset = function reset() { + var hooked = this; + + return bufferedSpawn(hooked.git, ['reset', '--hard', '--quiet'], { + cwd: hooked.root, + stdio: 'inherit' + }) + .then(discardResult); +}; + +/** + * Perform setup tasks before running scripts. + * + * @returns {Promise} A promise which is resolved when setup is complete. + * @api private + */ +Hook.prototype._setup = function setup() { + var hooked = this; + + if (!hooked.config.stash) { + // No pre-run setup required + return Promise.resolve(); + } + + // Stash any changes not included in the commit. + // Based on https://stackoverflow.com/a/20480591 + return hooked._getGitHash('refs/stash') + .then(function (oldStashHash) { + return hooked._stash() + .then(function () { + return hooked._getGitHash('refs/stash'); + }) + .then(function (newStashHash) { + hooked.stashed = newStashHash !== oldStashHash; + }); + }); +}; + +/** + * Perform cleanup tasks after scripts have run. + * + * @returns {Promise} A promise which is resolved when cleanup is complete. + * The promise is never rejected. Any failures are logged. + * @api private + */ +Hook.prototype._cleanup = function cleanup() { + var hooked = this; + var stashConfig = hooked.config.stash; + + if (!stashConfig) { + // No post-run cleanup required + return Promise.resolve(); + } + + var cleanupResult = Promise.resolve(); + + if (stashConfig.reset) { + cleanupResult = promiseFinally(cleanupResult, function () { + return hooked._reset(); + }); + } + + if (stashConfig.clean) { + cleanupResult = promiseFinally(cleanupResult, function () { + return hooked._clean(); + }); + } + + if (hooked.stashed) { + cleanupResult = promiseFinally(cleanupResult, function () { + return hooked._unstash(); + }); + } + + return cleanupResult.then( + discardResult, + function (err) { + hooked.logOnly(hooked.format(Hook.log.unstash, err)); + // Not propagating error. Cleanup failure shouldn't abort commit. + } + ); +}; + +/** + * Run an npm script. + * + * @param {string} script Script name (as in package.json) + * @returns {Promise} Promise which is resolved if the script completes + * successfully, rejected with an Error if the script can't be run or exits + * with non-0 exit code. + * @api private + */ +Hook.prototype._runScript = function runScript(script) { + var hooked = this; + + // 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. + // + return bufferedSpawn(hooked.npm, ['run', script, '--silent'], { + cwd: hooked.root, + stdio: 'inherit' + }) + .catch(function (err) { + // Add script name to error to simplify error handling + err.script = script; + return Promise.reject(err); + }) + .then(discardResult); +}; + +/** + * Run the configured hook scripts. + * + * @returns {Promise} Promise which is resolved after all hook scripts have + * completed. Promise is rejected with an Error if any script fails. + * @api private + */ +Hook.prototype._runScripts = function runScripts() { + var hooked = this; + var scripts = hooked.config.run; + + return scripts.reduce(function (prev, script) { + // Each script starts after the previous script succeeds + return prev.then(function () { + return hooked._runScript(script); + }); + }, Promise.resolve()) + .then(discardResult); +}; + /** * Run the specified hooks. * + * @returns {Promise} Promise which is resolved after setup, all hook scripts, + * and cleanup have completed. Promise is rejected with an Error if any script + * fails. * @api public */ Hook.prototype.run = function runner() { var hooked = this; + var scripts = hooked.config.run; - (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)); + if (!scripts.length) { + hooked.exit(0); + return Promise.resolve(); + } - again(scripts); - }); - })(hooked.config.run.slice(0)); + function setupFailed(err) { + hooked.log(hooked.format(Hook.log.setup, err), 0); + return Promise.reject(err); + } + + function scriptFailed(err) { + var script = err.script; + var code = err.status; + hooked.log(hooked.format(Hook.log.failure, script, code), code); + return Promise.reject(err); + } + + function scriptsPassed() { + hooked.exit(0); + } + + function setupDone() { + // Run scripts, then unconditionally run cleanup without changing result + return promiseFinally(hooked._runScripts(), function () { + return hooked._cleanup(); + }) + .then(scriptsPassed, scriptFailed); + } + + var result = hooked._setup().then(setupDone, setupFailed); + result._mustHandle = true; + return result; }; +// For API compatibility with previous versions, where asynchronous exceptions +// were always unhandled, if the promise we return results in an unhandled +// rejection, convert that to an exception. +// Note: If the caller chains anything to it, the new Promise would be +// unhandled if the chain does not include a handler. +process.on('unhandledRejection', function checkMustHandle(reason, p) { + if (p._mustHandle) { + throw reason; + } +}); + /** * Expose some of our internal tools so plugins can also re-use them for their * own processing. @@ -280,6 +575,11 @@ Hook.log = { 'Skipping the pre-commit hook.' ].join('\n'), + setup: [ + 'Error preparing repository for pre-commit hook scripts to run: %s', + '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.' @@ -298,6 +598,13 @@ Hook.log = { 'Skipping the pre-commit hook.' ].join('\n'), + unstash: [ + 'Unable to reset/clean and re-apply the pre-commit stash: %s', + '', + 'Please fix any errors printed by git then re-run `git stash pop` to', + 'restore the working directory to its previous state.' + ].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.', diff --git a/package.json b/package.json index 163d339..13eb592 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "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", + "test-all": "npm run test && npm run test-stash", + "test-stash": "mocha test-stash.js", + "test-travis": "istanbul cover node_modules/.bin/_mocha --report lcovonly -- test.js test-stash.js", "uninstall": "node uninstall.js" }, "repository": { @@ -30,13 +32,18 @@ "homepage": "https://github.com/observing/pre-commit", "license": "MIT", "dependencies": { + "bluebird": "3.2.x", + "buffered-spawn": "2.0.x", "cross-spawn": "2.0.x", + "promise-finally": "2.1.x", "which": "1.2.x" }, "devDependencies": { "assume": "1.3.x", "istanbul": "0.4.x", "mocha": "2.3.x", - "pre-commit": "git://github.com/observing/pre-commit.git" + "pify": "2.3.x", + "pre-commit": "git://github.com/observing/pre-commit.git", + "rimraf": "2.5.x" } } diff --git a/test-stash.js b/test-stash.js new file mode 100644 index 0000000..5a25957 --- /dev/null +++ b/test-stash.js @@ -0,0 +1,450 @@ +/* istanbul ignore next */ +'use strict'; + +/* These tests are split out into a separate file primarily to avoid running + * them on every commit (which is slow). + */ + +var Hook = require('./') + , assume = require('assume') + , buffSpawn = require('buffered-spawn') + , fs = require('fs') + , path = require('path') + , pify = require('pify') + , rimraf = require('rimraf') + , which = require('which'); + +/** Path to repository in which tests are run. */ +var TEST_REPO_PATH = path.join(__dirname, 'test-repo'); + +/** Name of a test file which is ignored by git. */ +var IGNORED_FILE_NAME = 'ignored.txt'; + +/** Name of an empty file committed to the git repository. */ +var TRACKED_FILE_NAME = 'tracked.txt'; + +/** Name of a file which is not committed or ignored by git. */ +var UNTRACKED_FILE_NAME = 'untracked.txt'; + +/** Content which is written to files by scripts. */ +var SCRIPT_CONTENT = 'script-modified'; +var SCRIPT_CONTENT_BUFF = new Buffer(SCRIPT_CONTENT + '\n'); + +/** Content which is written to files by tests. */ +var TEST_CONTENT = 'test-modified'; +var TEST_CONTENT_BUFF = new Buffer(TEST_CONTENT + '\n'); + +/** Content for package.json in the test repository. */ +var PACKAGE_JSON = { + name: 'pre-commit-test', + scripts: { + 'modify-ignored': 'echo ' + SCRIPT_CONTENT + ' > ' + IGNORED_FILE_NAME, + 'modify-tracked': 'echo ' + SCRIPT_CONTENT + ' > ' + TRACKED_FILE_NAME, + 'modify-untracked': 'echo ' + SCRIPT_CONTENT + ' > ' + UNTRACKED_FILE_NAME, + 'test': 'exit 0', + 'test-ignored-exists': 'test -e ' + IGNORED_FILE_NAME, + 'test-tracked-empty': 'test ! -s ' + TRACKED_FILE_NAME, + 'test-untracked-exists': 'test -e ' + UNTRACKED_FILE_NAME + } +}; + +// Global variables +var gitPath + , readFileP = pify(fs.readFile) + , rimrafP = pify(rimraf) + , statP = pify(fs.stat) + , whichP = pify(which) + , writeFileP = pify(fs.writeFile); + +/** + * Find git in $PATH and set gitPath global. + * @returns {Promise} Promise with the path to git. + */ +function findGit() { + return whichP('git').then(function (whichGit) { + gitPath = whichGit; + return whichGit; + }); +} + +/** + * Run git with given arguments and options. + * @returns {Promise} Promise with the process output or Error for non-0 exit. + */ +function git(/* [args...], [options] */) { + if (!gitPath) { + var origArgs = arguments; + return findGit().then(function () { + return git.apply(null, origArgs); + }); + } + + // Default to redirecting stdin (to prevent unexpected prompts) and + // including any output with test output + var defaultStdio = ['ignore', process.stdout, process.stderr]; + + var args, options; + if ('object' === typeof arguments[arguments.length - 1]) { + args = Array.prototype.slice.call(arguments); + options = args.pop(); + options.stdio = options.stdio || defaultStdio; + } else { + // Note: spawn/buffSpawn requires Array type for arguments + args = Array.prototype.slice.call(arguments); + options = { + stdio: defaultStdio + }; + } + + return buffSpawn(gitPath, args, options); +} + +/** Create a stash and return its hash. */ +function createStash() { + // Modify a tracked file to ensure a stash is created. + return writeFileP(TRACKED_FILE_NAME, TEST_CONTENT_BUFF) + .then(function gitStash() { + return git('stash', 'save', '-q', 'test stash'); + }) + .then(function gitRevParse() { + var options = { stdio: ['ignore', 'pipe', process.stderr] }; + return git('rev-parse', '-q', '--verify', 'refs/stash', options); + }) + .then(function getOutput(result) { + return result.stdout; + }); +} + +/** Throw a given value. */ +function throwIt(err) { + throw err; +} + +describe('pre-commit stash support', function () { + var origCWD; + + before('test for unhandledRejection', function () { + // We want to ensure that all Promises are handled. + // Since Mocha does not watch for unhandledRejection, convert + // unhandledRejection to uncaughtException by throwing it. + // See: https://github.com/mochajs/mocha/issues/1926 + process.on('unhandledRejection', throwIt); + }); + + after('stop testing for unhandledRejection', function () { + // Limit the unhandledRejection guarantee to this spec + process.removeListener('unhandledRejection', throwIt); + }); + + before('setup test repository', function () { + return rimrafP(TEST_REPO_PATH) + .then(function createTestRepo() { + return git('init', '-q', TEST_REPO_PATH); + }) + // The user name and email must be configured for the later git commands + // to work. On Travis CI (and probably others) there is no global config + .then(function getConfigName() { + return git('-C', TEST_REPO_PATH, + 'config', 'user.name', 'Test User'); + }) + .then(function getConfigEmail() { + return git('-C', TEST_REPO_PATH, + 'config', 'user.email', 'test@example.com'); + }) + .then(function createFiles() { + return Promise.all([ + writeFileP( + path.join(TEST_REPO_PATH, '.gitignore'), + IGNORED_FILE_NAME + '\n' + ), + writeFileP( + path.join(TEST_REPO_PATH, 'package.json'), + JSON.stringify(PACKAGE_JSON, null, 2) + ), + writeFileP( + path.join(TEST_REPO_PATH, TRACKED_FILE_NAME), + new Buffer(0) + ) + ]); + }) + .then(function addFiles() { + return git('-C', TEST_REPO_PATH, 'add', '.'); + }) + .then(function createCommit() { + return git('-C', TEST_REPO_PATH, + 'commit', '-q', '-m', 'Initial Commit'); + }); + }); + + after('remove test repository', function () { + return rimrafP(TEST_REPO_PATH); + }); + + before('run from test repository', function () { + origCWD = process.cwd(); + process.chdir(TEST_REPO_PATH); + }); + + after('restore original working directory', function () { + process.chdir(origCWD); + }); + + beforeEach('cleanup test repository', function () { + // Ensure the test repository is in a pristine state + return git('-C', TEST_REPO_PATH, 'reset', '-q', '--hard') + .then(function clean() { + return git('clean', '-qdxf'); + }) + .then(function clearStash() { + return git('stash', 'clear'); + }); + }); + + it('should not stash by default', function () { + return writeFileP(TRACKED_FILE_NAME, TEST_CONTENT_BUFF) + .then(function doHook() { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['test-tracked-empty']; + return hook.run(); + }) + .then( + function () { throw new Error('Expected script error'); }, + function (err) { assume(err.script).equals('test-tracked-empty'); } + ); + }); + + it('should not stash without scripts to run', function () { + return writeFileP(UNTRACKED_FILE_NAME, TEST_CONTENT_BUFF) + .then(function doHook() { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = []; + hook.config.stash = { + clean: true, + includeUntracked: true + }; + return hook.run(); + }) + .then(function readUntracked() { + return readFileP(UNTRACKED_FILE_NAME); + }) + .then(function checkContent(content) { + assume(content).eql(TEST_CONTENT_BUFF); + }); + }); + + it('should stash and restore modified files', function () { + return writeFileP(TRACKED_FILE_NAME, TEST_CONTENT_BUFF) + .then(function doHook() { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['test-tracked-empty']; + hook.config.stash = true; + return hook.run(); + }) + .then(function readTracked() { + return readFileP(TRACKED_FILE_NAME); + }) + .then(function checkContent(content) { + assume(content).eql(TEST_CONTENT_BUFF); + }); + }); + + it('should stash and restore modified files on script error', function () { + return writeFileP(TRACKED_FILE_NAME, TEST_CONTENT_BUFF) + .then(function doHook() { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['test-untracked-exists']; + hook.config.stash = true; + return hook.run(); + }) + .catch(function checkError(err) { + assume(err.script).eql('test-untracked-exists'); + }) + .then(function readTracked() { + return readFileP(TRACKED_FILE_NAME); + }) + .then(function checkContent(content) { + assume(content).eql(TEST_CONTENT_BUFF); + }); + }); + + // Since a stash is not created if there are no changes, this check is + // necessary. + it('should not touch the existing stash', function () { + return createStash() + .then(function hookAndCheck(oldHash) { + assume(oldHash).is.not.falsey(); + + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['test-tracked-empty']; + hook.config.stash = true; + return hook.run() + .then(function getStashHash() { + return hook._getGitHash('refs/stash'); + }) + .then(function checkStashHash(newHash) { + assume(newHash).equals(oldHash); + }); + }); + }); + + it('should not stash untracked files by default', function () { + return writeFileP(UNTRACKED_FILE_NAME, TEST_CONTENT_BUFF) + .then(function doHook() { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['test-untracked-exists']; + hook.config.stash = true; + return hook.run(); + }); + }); + + it('can stash and restore untracked files', function () { + return writeFileP(UNTRACKED_FILE_NAME, TEST_CONTENT_BUFF) + .then(function doHook() { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['test-untracked-exists']; + hook.config.stash = { + includeUntracked: true + }; + return hook.run(); + }) + .catch(function checkError(err) { + assume(err.script).eql('test-untracked-exists'); + }) + .then(function readUntracked() { + return readFileP(UNTRACKED_FILE_NAME); + }) + .then(function checkContent(content) { + assume(content).eql(TEST_CONTENT_BUFF); + }); + }); + + it('should not stash ignored files by default', function () { + return writeFileP(IGNORED_FILE_NAME, TEST_CONTENT_BUFF) + .then(function doHook() { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['test-ignored-exists']; + hook.config.stash = true; + return hook.run(); + }); + }); + + it('can stash and restore ignored files', function () { + return writeFileP(IGNORED_FILE_NAME, TEST_CONTENT_BUFF) + .then(function doHook() { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['test-ignored-exists']; + hook.config.stash = { + includeAll: true + }; + return hook.run(); + }) + .catch(function checkError(err) { + assume(err.script).eql('test-ignored-exists'); + }) + .then(function readIgnored() { + return readFileP(IGNORED_FILE_NAME); + }) + .then(function checkContent(content) { + assume(content).eql(TEST_CONTENT_BUFF); + }); + }); + + it('should not clean by default', function () { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['modify-untracked']; + hook.config.stash = true; + return hook.run() + .then(function readUntracked() { + return readFileP(UNTRACKED_FILE_NAME); + }) + .then(function checkContent(content) { + assume(content).eql(SCRIPT_CONTENT_BUFF); + }); + }); + + it('can clean', function () { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['modify-untracked']; + hook.config.stash = { + clean: true + }; + return hook.run() + .then(function readUntracked() { + return statP(UNTRACKED_FILE_NAME); + }) + .then( + function () { + throw new Error('Expected ' + UNTRACKED_FILE_NAME + + ' to be cleaned'); + }, + function (err) { + assume(err.code).equals('ENOENT'); + } + ); + }); + + it('should not clean ignored files by default', function () { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['modify-ignored']; + hook.config.stash = { + clean: true + }; + return hook.run() + .then(function readIgnored() { + return readFileP(IGNORED_FILE_NAME); + }) + .then(function checkContent(content) { + assume(content).eql(SCRIPT_CONTENT_BUFF); + }); + }); + + it('can clean ignored files', function () { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['modify-ignored']; + hook.config.stash = { + includeAll: true, + clean: true + }; + return hook.run() + .then(function readIgnored() { + return statP(IGNORED_FILE_NAME); + }) + .then( + function () { + throw new Error('Expected ' + IGNORED_FILE_NAME + + ' to be cleaned'); + }, + function (err) { + assume(err.code).equals('ENOENT'); + } + ); + }); + + it('should not reset modified files by default', function () { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['modify-tracked']; + hook.config.stash = true; + return hook.run() + .then(function readTracked() { + return readFileP(TRACKED_FILE_NAME); + }) + .then(function checkContent(content) { + assume(content).eql(SCRIPT_CONTENT_BUFF); + }); + }); + + it('can reset modified files', function () { + var hook = new Hook(function () {}, { ignorestatus: true }); + hook.config.run = ['modify-tracked']; + hook.config.stash = { + reset: true + }; + return hook.run() + .then(function readTracked() { + return readFileP(TRACKED_FILE_NAME); + }) + .then(function checkContent(content) { + assume(content).eql(new Buffer(0)); + }); + }); +});