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 a20646d..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]; @@ -112,13 +121,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 +144,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; }; @@ -204,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. @@ -263,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.' @@ -281,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)); + }); + }); +});