Repository: conventional-changelog/standard-version
Pull request #187 (2017-06-05 02:22:38)

feat: add dry-run mode
running standard-version with the flag --dry-run allows you to see what commands would be run, without committing to git or updating files.

@azachar I've implemented your suggestion for dry-run mode here 👍 

coveralls: 
[![Coverage Status](https://coveralls.io/builds/11825125/badge)](https://coveralls.io/builds/11825125)

Coverage increased (+0.01%) to 99.627% when pulling **42e77c63edb7daa556bb6699fdf7292c1168c91b on dry-run** into **dfd1d12939d8e8a53efe4a012faeaa797219cd49 on master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/11825258/badge)](https://coveralls.io/builds/11825258)

Coverage increased (+0.01%) to 99.627% when pulling **3f230726c565f32a65d6e45ab2011cf5a45b343b on dry-run** into **dfd1d12939d8e8a53efe4a012faeaa797219cd49 on master**.


Patch:
diff --git a/README.md b/README.md
index 1440caf..9dc47ae 100644
--- a/README.md
+++ b/README.md
@@ -188,6 +188,18 @@ If you want to commit generated artifacts in the release commit (e.g. [#96](http
 "release": "git add <file(s) to commit> && standard-version -a"
 ```
 
+### Dry run mode
+
+running `standard-version` with the flag `--dry-run` allows you to see what
+commands would be run, without committing to git or updating files.
+
+```sh
+# npm run script
+npm run release -- --dry-run
+# or global bin
+standard-version --dry-run
+```
+
 ### CLI Help
 
 ```sh
diff --git a/command.js b/command.js
index 0c41c6b..3c65226 100755
--- a/command.js
+++ b/command.js
@@ -6,76 +6,71 @@ module.exports = require('yargs')
     alias: 'r',
     describe: 'Specify the release type manually (like npm version <major|minor|patch>)',
     requiresArg: true,
-    string: true,
-    global: true
+    string: true
   })
   .option('prerelease', {
     alias: 'p',
     describe: 'make a pre-release with optional option value to specify a tag id',
-    string: true,
-    global: true
+    string: true
   })
   .option('infile', {
     alias: 'i',
     describe: 'Read the CHANGELOG from this file',
-    default: defaults.infile,
-    global: true
+    default: defaults.infile
   })
   .option('message', {
     alias: 'm',
     describe: 'Commit message, replaces %s with new version',
     type: 'string',
-    default: defaults.message,
-    global: true
+    default: defaults.message
   })
   .option('first-release', {
     alias: 'f',
     describe: 'Is this the first release?',
     type: 'boolean',
-    default: defaults.firstRelease,
-    global: true
+    default: defaults.firstRelease
   })
   .option('sign', {
     alias: 's',
     describe: 'Should the git commit and tag be signed?',
     type: 'boolean',
-    default: defaults.sign,
-    global: true
+    default: defaults.sign
   })
   .option('no-verify', {
     alias: 'n',
     describe: 'Bypass pre-commit or commit-msg git hooks during the commit phase',
     type: 'boolean',
-    default: defaults.noVerify,
-    global: true
+    default: defaults.noVerify
   })
   .option('commit-all', {
     alias: 'a',
     describe: 'Commit all staged changes, not just files affected by standard-version',
     type: 'boolean',
-    default: defaults.commitAll,
-    global: true
+    default: defaults.commitAll
   })
   .option('silent', {
     describe: 'Don\'t print logs and errors',
     type: 'boolean',
-    default: defaults.silent,
-    global: true
+    default: defaults.silent
   })
   .option('tag-prefix', {
     alias: 't',
     describe: 'Set a custom prefix for the git tag to be created',
     type: 'string',
-    default: defaults.tagPrefix,
-    global: true
+    default: defaults.tagPrefix
   })
   .option('scripts', {
     describe: 'Scripts to execute for lifecycle events (prebump, precommit, etc.,)',
-    default: {}
+    default: defaults.scripts
+  })
+  .option('dry-run', {
+    type: 'boolean',
+    default: defaults.dryRun,
+    describe: 'See the commands that running standard-version would run'
   })
   .check((argv) => {
     if (typeof argv.scripts !== 'object' || Array.isArray(argv.scripts)) {
-      throw Error('hooks must be an object')
+      throw Error('scripts must be an object')
     } else {
       return true
     }
diff --git a/defaults.json b/defaults.json
index 26ef43c..b737c0c 100644
--- a/defaults.json
+++ b/defaults.json
@@ -6,5 +6,7 @@
   "noVerify": false,
   "commitAll": false,
   "silent": false,
-  "tagPrefix": "v"
+  "tagPrefix": "v",
+  "scripts": {},
+  "dryRun": false
 }
diff --git a/index.js b/index.js
index 73e5dd1..5b56f3c 100755
--- a/index.js
+++ b/index.js
@@ -13,16 +13,16 @@ const checkpoint = require('./lib/checkpoint')
 const printError = require('./lib/print-error')
 const runExec = require('./lib/run-exec')
 const runLifecycleScript = require('./lib/run-lifecycle-script')
+const writeFile = require('./lib/write-file')
 
 module.exports = function standardVersion (argv) {
   var pkgPath = path.resolve(process.cwd(), './package.json')
   var pkg = require(pkgPath)
   var newVersion = pkg.version
-  var scripts = argv.scripts || {}
   var defaults = require('./defaults')
   var args = Object.assign({}, defaults, argv)
 
-  return runLifecycleScript(args, 'prebump', null, scripts)
+  return runLifecycleScript(args, 'prebump', null)
     .then((stdout) => {
       if (stdout && stdout.trim().length) args.releaseAs = stdout.trim()
       return bumpVersion(args.releaseAs)
@@ -36,13 +36,13 @@ module.exports = function standardVersion (argv) {
         checkpoint(args, 'skip version bump on first release', [], chalk.red(figures.cross))
       }
 
-      return runLifecycleScript(args, 'postbump', newVersion, scripts)
+      return runLifecycleScript(args, 'postbump', newVersion, args)
     })
     .then(() => {
-      return outputChangelog(args)
+      return outputChangelog(args, newVersion)
     })
     .then(() => {
-      return runLifecycleScript(args, 'precommit', newVersion, scripts)
+      return runLifecycleScript(args, 'precommit', newVersion, args)
     })
     .then((message) => {
       if (message && message.length) args.message = message
@@ -61,7 +61,7 @@ module.exports = function standardVersion (argv) {
  * attempt to update the version # in a collection of common config
  * files, e.g., package.json, bower.json.
  *
- * @param argv config object
+ * @param args config object
  * @param newVersion version # to update to.
  * @return {string}
  */
@@ -78,7 +78,7 @@ function updateConfigs (args, newVersion) {
         var filename = path.basename(configPath)
         checkpoint(args, 'bumping version in ' + filename + ' from %s to %s', [config.version, newVersion])
         config.version = newVersion
-        fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
+        writeFile(args, configPath, JSON.stringify(config, null, 2) + '\n')
         // flag any config files that we modify the version # for
         // as having been updated.
         configsToUpdate[configPath] = true
@@ -171,19 +171,21 @@ function bumpVersion (releaseAs, callback) {
   })
 }
 
-function outputChangelog (argv) {
+function outputChangelog (args, newVersion) {
   return new Promise((resolve, reject) => {
-    createIfMissing(argv)
+    createIfMissing(args)
     var header = '# Change Log\n\nAll notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.\n'
-    var oldContent = fs.readFileSync(argv.infile, 'utf-8')
+    var oldContent = args.dryRun ? '' : fs.readFileSync(args.infile, 'utf-8')
     // find the position of the last release and remove header:
     if (oldContent.indexOf('<a name=') !== -1) {
       oldContent = oldContent.substring(oldContent.indexOf('<a name='))
     }
     var content = ''
+    var context
+    if (args.dryRun) context = {version: newVersion}
     var changelogStream = conventionalChangelog({
       preset: 'angular'
-    }, undefined, {merges: null})
+    }, context, {merges: null})
       .on('error', function (err) {
         return reject(err)
       })
@@ -193,31 +195,32 @@ function outputChangelog (argv) {
     })
 
     changelogStream.on('end', function () {
-      checkpoint(argv, 'outputting changes to %s', [argv.infile])
-      fs.writeFileSync(argv.infile, header + '\n' + (content + oldContent).replace(/\n+$/, '\n'), 'utf-8')
+      checkpoint(args, 'outputting changes to %s', [args.infile])
+      if (args.dryRun) console.info(`\n---\n${chalk.gray(content.trim())}\n---\n`)
+      else writeFile(args, args.infile, header + '\n' + (content + oldContent).replace(/\n+$/, '\n'))
       return resolve()
     })
   })
 }
 
-function commit (argv, newVersion) {
+function commit (args, newVersion) {
   var msg = 'committing %s'
-  var args = [argv.infile]
-  var verify = argv.verify === false || argv.n ? '--no-verify ' : ''
+  var paths = [args.infile]
+  var verify = args.verify === false || args.n ? '--no-verify ' : ''
   var toAdd = ''
   // commit any of the config files that we've updated
   // the version # for.
   Object.keys(configsToUpdate).forEach(function (p) {
     if (configsToUpdate[p]) {
       msg += ' and %s'
-      args.unshift(path.basename(p))
+      paths.unshift(path.basename(p))
       toAdd += ' ' + path.relative(process.cwd(), p)
     }
   })
-  checkpoint(argv, msg, args)
-  return runExec(argv, 'git add' + toAdd + ' ' + argv.infile)
+  checkpoint(args, msg, paths)
+  return runExec(args, 'git add' + toAdd + ' ' + args.infile)
     .then(() => {
-      return runExec(argv, 'git commit ' + verify + (argv.sign ? '-S ' : '') + (argv.commitAll ? '' : (argv.infile + toAdd)) + ' -m "' + formatCommitMessage(argv.message, newVersion) + '"')
+      return runExec(args, 'git commit ' + verify + (args.sign ? '-S ' : '') + (args.commitAll ? '' : (args.infile + toAdd)) + ' -m "' + formatCommitMessage(args.message, newVersion) + '"')
     })
 }
 
@@ -225,31 +228,31 @@ function formatCommitMessage (msg, newVersion) {
   return String(msg).indexOf('%s') !== -1 ? util.format(msg, newVersion) : msg
 }
 
-function tag (newVersion, pkgPrivate, argv) {
+function tag (newVersion, pkgPrivate, args) {
   var tagOption
-  if (argv.sign) {
+  if (args.sign) {
     tagOption = '-s '
   } else {
     tagOption = '-a '
   }
-  checkpoint(argv, 'tagging release %s', [newVersion])
-  return runExec(argv, 'git tag ' + tagOption + argv.tagPrefix + newVersion + ' -m "' + formatCommitMessage(argv.message, newVersion) + '"')
+  checkpoint(args, 'tagging release %s', [newVersion])
+  return runExec(args, 'git tag ' + tagOption + args.tagPrefix + newVersion + ' -m "' + formatCommitMessage(args.message, newVersion) + '"')
     .then(() => {
       var message = 'git push --follow-tags origin master'
       if (pkgPrivate !== true) message += '; npm publish'
 
-      checkpoint(argv, 'Run `%s` to publish', [message], chalk.blue(figures.info))
+      checkpoint(args, 'Run `%s` to publish', [message], chalk.blue(figures.info))
     })
 }
 
-function createIfMissing (argv) {
+function createIfMissing (args) {
   try {
-    accessSync(argv.infile, fs.F_OK)
+    accessSync(args.infile, fs.F_OK)
   } catch (err) {
     if (err.code === 'ENOENT') {
-      checkpoint(argv, 'created %s', [argv.infile])
-      argv.outputUnreleased = true
-      fs.writeFileSync(argv.infile, '\n', 'utf-8')
+      checkpoint(args, 'created %s', [args.infile])
+      args.outputUnreleased = true
+      writeFile(args, args.infile, '\n')
     }
   }
 }
diff --git a/lib/checkpoint.js b/lib/checkpoint.js
index ff7f5d1..516ab56 100644
--- a/lib/checkpoint.js
+++ b/lib/checkpoint.js
@@ -2,9 +2,10 @@ const chalk = require('chalk')
 const figures = require('figures')
 const util = require('util')
 
-module.exports = function (argv, msg, args, figure) {
-  if (!argv.silent) {
-    console.info((figure || chalk.green(figures.tick)) + ' ' + util.format.apply(util, [msg].concat(args.map(function (arg) {
+module.exports = function (args, msg, vars, figure) {
+  const defaultFigure = args.dryRun ? chalk.yellow(figures.tick) : chalk.green(figures.tick)
+  if (!args.silent) {
+    console.info((figure || defaultFigure) + ' ' + util.format.apply(util, [msg].concat(vars.map(function (arg) {
       return chalk.bold(arg)
     }))))
   }
diff --git a/lib/print-error.js b/lib/print-error.js
index 983aeac..84aaa82 100644
--- a/lib/print-error.js
+++ b/lib/print-error.js
@@ -1,7 +1,7 @@
 const chalk = require('chalk')
 
-module.exports = function (argv, msg, opts) {
-  if (!argv.silent) {
+module.exports = function (args, msg, opts) {
+  if (!args.silent) {
     opts = Object.assign({
       level: 'error',
       color: 'red'
diff --git a/lib/run-exec.js b/lib/run-exec.js
index bfca976..4ce3cb0 100644
--- a/lib/run-exec.js
+++ b/lib/run-exec.js
@@ -1,17 +1,18 @@
 const exec = require('child_process').exec
 const printError = require('./print-error')
 
-module.exports = function (argv, cmd) {
+module.exports = function (args, cmd) {
+  if (args.dryRun) return Promise.resolve()
   return new Promise((resolve, reject) => {
     // Exec given cmd and handle possible errors
     exec(cmd, function (err, stdout, stderr) {
       // If exec returns content in stderr, but no error, print it as a warning
       // If exec returns an error, print it and exit with return code 1
       if (err) {
-        printError(argv, stderr || err.message)
+        printError(args, stderr || err.message)
         return reject(err)
       } else if (stderr) {
-        printError(argv, stderr, {level: 'warn', color: 'yellow'})
+        printError(args, stderr, {level: 'warn', color: 'yellow'})
       }
       return resolve(stdout)
     })
diff --git a/lib/run-lifecycle-hook.js b/lib/run-lifecycle-hook.js
index caaeeba..17efd33 100644
--- a/lib/run-lifecycle-hook.js
+++ b/lib/run-lifecycle-hook.js
@@ -3,10 +3,10 @@ const checkpoint = require('./checkpoint')
 const figures = require('figures')
 const runExec = require('./run-exec')
 
-module.exports = function (argv, hookName, newVersion, hooks, cb) {
+module.exports = function (args, hookName, newVersion, hooks, cb) {
   if (!hooks[hookName]) return Promise.resolve()
   var command = hooks[hookName] + ' --new-version="' + newVersion + '"'
-  checkpoint(argv, 'Running lifecycle hook "%s"', [hookName])
-  checkpoint(argv, '- hook command: "%s"', [command], chalk.blue(figures.info))
-  return runExec(argv, command)
+  checkpoint(args, 'Running lifecycle hook "%s"', [hookName])
+  checkpoint(args, '- hook command: "%s"', [command], chalk.blue(figures.info))
+  return runExec(args, command)
 }
diff --git a/lib/run-lifecycle-script.js b/lib/run-lifecycle-script.js
index 2ad322b..f4c2baa 100644
--- a/lib/run-lifecycle-script.js
+++ b/lib/run-lifecycle-script.js
@@ -3,11 +3,12 @@ const checkpoint = require('./checkpoint')
 const figures = require('figures')
 const runExec = require('./run-exec')
 
-module.exports = function (argv, hookName, newVersion, scripts, cb) {
-  if (!scripts[hookName]) return Promise.resolve()
+module.exports = function (args, hookName, newVersion) {
+  const scripts = args.scripts
+  if (!scripts || !scripts[hookName]) return Promise.resolve()
   var command = scripts[hookName]
   if (newVersion) command += ' --new-version="' + newVersion + '"'
-  checkpoint(argv, 'Running lifecycle script "%s"', [hookName])
-  checkpoint(argv, '- execute command: "%s"', [command], chalk.blue(figures.info))
-  return runExec(argv, command)
+  checkpoint(args, 'Running lifecycle script "%s"', [hookName])
+  checkpoint(args, '- execute command: "%s"', [command], chalk.blue(figures.info))
+  return runExec(args, command)
 }
diff --git a/lib/write-file.js b/lib/write-file.js
new file mode 100644
index 0000000..b6aaa19
--- /dev/null
+++ b/lib/write-file.js
@@ -0,0 +1,6 @@
+const fs = require('fs')
+
+module.exports = function (args, filePath, content) {
+  if (args.dryRun) return
+  fs.writeFileSync(filePath, content, 'utf8')
+}


Test patch:
diff --git a/test.js b/test.js
index 77db31d..e1702fd 100644
--- a/test.js
+++ b/test.js
@@ -675,4 +675,17 @@ describe('standard-version', function () {
         })
     })
   })
+
+  describe('dry-run', function () {
+    it('skips all non-idempotent steps', function (done) {
+      commit('feat: first commit')
+      shell.exec('git tag -a v1.0.0 -m "my awesome first release"')
+      commit('feat: new feature!')
+      execCli('--dry-run').stdout.should.match(/### Features/)
+      shell.exec('git log --oneline -n1').stdout.should.match(/feat: new feature!/)
+      shell.exec('git tag').stdout.should.match(/1\.0\.0/)
+      getPackageVersion().should.equal('1.0.0')
+      return done()
+    })
+  })
 })