Repository: okonet/lint-staged
Pull request #304 (2017-10-07 06:18:48)

Allow config to be provided via command-line
### Summary
I use `lint-staged` as a pre-commit hook and would like to explicitly provide the config file location. This PR uses [`commander`](npmjs.com/package/commander) to parse the command-line argument and passes it as `cosmiconfig`'s [`configPath`](https://github.com/davidtheclark/cosmiconfig#configpath) option. This allows the config file to be any format already supported.

### Test plan
I've added tests and updated the documentation.

sudo-suhas: @cameronhunter Could you investigate the CI failures?
codecov[bot]: # [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/304?src=pr&el=h1) Report
> Merging [#304](https://codecov.io/gh/okonet/lint-staged/pull/304?src=pr&el=desc) into [master](https://codecov.io/gh/okonet/lint-staged/commit/50416a94d6e4280b37e8a0d8cdf867910f042af1?src=pr&el=desc) will **increase** coverage by `0.58%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/okonet/lint-staged/pull/304/graphs/tree.svg?token=LSoEgsjk4O&height=150&src=pr&width=650)](https://codecov.io/gh/okonet/lint-staged/pull/304?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #304      +/-   ##
==========================================
+ Coverage   91.86%   92.44%   +0.58%     
==========================================
  Files          10       10              
  Lines         172      172              
  Branches       26       26              
==========================================
+ Hits          158      159       +1     
+ Misses         13       12       -1     
  Partials        1        1
```


| [Impacted Files](https://codecov.io/gh/okonet/lint-staged/pull/304?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/index.js](https://codecov.io/gh/okonet/lint-staged/pull/304?src=pr&el=tree#diff-c3JjL2luZGV4Lmpz) | `80.64% <100%> (+3.22%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/okonet/lint-staged/pull/304?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/304?src=pr&el=footer). Last update [50416a9...99d44b9](https://codecov.io/gh/okonet/lint-staged/pull/304?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

cameronhunter: @sudo-suhas The CI failures were:

1. Cross-platform difference in paths between Windows and Linux. Resolved using a jest serializer.
2. In some CI setups `Listr` decides to be verbose and write more to the console, which meant the snapshots were inconsistent. Resolved by injecting the console logger instead of mocking the global console.
sudo-suhas: Hey @cameronhunter, thanks for this. I did not know about the jest snapshot serializer. Cool stuff.

Regarding 99d44b9, do you think it is possible that the whitespace issue was because the editor removed trailing whitespace? I personally have that enabled in vscode.

> In some CI setups `Listr` decides to be verbose and write more to the console

Actually, I don't think this is the case. I am only guessing, but I think this has something to do with running the code in sync vs async. In all our current tests, since `cosmiconfig` was being mocked, we only captured the console logs which were printed by us. And _after_ that `listr` wrote some more logs. However, in the test you added, cosmiconfig ran async and by the time we `await`ed for `lintStaged`, `listr` had already pushed some logs to console. For some reason, this isn't happening locally. Anyway, `listr` behavior isn't different but our way of capturing `console` logs is buggy. So I think it is the right approach to inject the logger. However, I think a lot of complexity was added in the way it has been implemented now. I took some of your commits and tweaked them in #311. Could you please remove commits ce2c8d7, 327f1c7, 58b034a, 505654a, 40b399a, afe2fac and port the changes from d512c63 instead? You can use [git reset](https://stackoverflow.com/questions/1338728/delete-commits-from-a-branch-in-git) to remove commits. Let me know if you need any help.
cameronhunter: @sudo-suhas I've rebased on `master`, squashed to a single commit and used `npm run cz` for formatting the commit message. 

I reordered the parameters to the main lib to pass the logger first (with a default value of `console`) so that it doesn't cause a breaking change. Overall it this should just be a minor version bump.

CI is passing – let me know if there's anything more you'd like included before merging.
cameronhunter: @sudo-suhas Can you speak to a timeline of merging and publishing the new version?
sudo-suhas: I figured I'd wait a couple of days for comments, if any, from @okonet and @luftywiranda13. I'll wait till end of day and merge it in. It should be released immediately via semantic-release.
luftywiranda13: is it better if we also add some additional helpful things for the `--help` page?

Patch:
diff --git a/README.md b/README.md
index 615fae0..d47a1cc 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,7 @@ Starting with v3.1 you can now use different ways of configuring it:
 * `lint-staged` object in your `package.json`
 * `.lintstagedrc` file in JSON or YML format
 * `lint-staged.config.js` file in JS format
+* Pass a configuration file using the `--config` or `-c` flag
 
 See [cosmiconfig](https://github.com/davidtheclark/cosmiconfig) for more details on what formats are supported.
 
diff --git a/index.js b/index.js
index 7ba3140..ae4632d 100755
--- a/index.js
+++ b/index.js
@@ -2,4 +2,12 @@
 
 'use strict'
 
-require('./src')()
+const cmdline = require('commander')
+const pkg = require('./package.json')
+
+cmdline
+  .version(pkg.version)
+  .option('-c, --config [path]', 'Path to configuration file')
+  .parse(process.argv)
+
+require('./src')(console, cmdline.config)
diff --git a/package.json b/package.json
index 93efc53..4423aef 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
   "dependencies": {
     "app-root-path": "^2.0.0",
     "chalk": "^2.1.0",
+    "commander": "^2.11.0",
     "cosmiconfig": "^1.1.0",
     "execa": "^0.8.0",
     "is-glob": "^4.0.0",
diff --git a/src/index.js b/src/index.js
index b9e22ba..bfd807e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -25,8 +25,11 @@ const errConfigNotFound = new Error('Config could not be found')
 /**
  * Root lint-staged function that is called from .bin
  */
-module.exports = function lintStaged() {
+module.exports = function lintStaged(injectedLogger, configPath) {
+  const logger = injectedLogger || console
+
   return cosmiconfig('lint-staged', {
+    configPath,
     rc: '.lintstagedrc',
     rcExtensions: true
   })
@@ -38,7 +41,7 @@ module.exports = function lintStaged() {
       const config = validateConfig(getConfig(result.config))
 
       if (config.verbose) {
-        console.log(`
+        logger.log(`
 Running lint-staged with the following config:
 ${stringifyObject(config)}
 `)
@@ -59,15 +62,15 @@ ${stringifyObject(config)}
     })
     .catch(err => {
       if (err === errConfigNotFound) {
-        console.error(`${err.message}.`)
+        logger.error(`${err.message}.`)
       } else {
         // It was probably a parsing error
-        console.error(`Could not parse lint-staged config.
+        logger.error(`Could not parse lint-staged config.
 
 ${err}`)
       }
       // Print helpful message for all errors
-      console.error(`
+      logger.error(`
 Please make sure you have created it correctly.
 See https://github.com/okonet/lint-staged#configuration.
 `)


Test patch:
diff --git a/test/__mocks__/cosmiconfig.js b/test/__mocks__/cosmiconfig.js
new file mode 100644
index 0000000..5dd4ca1
--- /dev/null
+++ b/test/__mocks__/cosmiconfig.js
@@ -0,0 +1,12 @@
+const actual = require.requireActual('cosmiconfig')
+const mock = jest.genMockFromModule('cosmiconfig')
+
+function cosmiconfig(name, options) {
+  if (options.configPath) {
+    return actual(name, options)
+  }
+
+  return mock(name, options)
+}
+
+module.exports = jest.fn(cosmiconfig)
diff --git a/test/__mocks__/my-config.json b/test/__mocks__/my-config.json
new file mode 100644
index 0000000..a5695fd
--- /dev/null
+++ b/test/__mocks__/my-config.json
@@ -0,0 +1,6 @@
+{
+  "verbose": true,
+  "linters": {
+    "*": "mytask"
+  }
+}
diff --git a/test/__snapshots__/index.spec.js.snap b/test/__snapshots__/index.spec.js.snap
index aa1e5b6..15f12dc 100644
--- a/test/__snapshots__/index.spec.js.snap
+++ b/test/__snapshots__/index.spec.js.snap
@@ -1,8 +1,30 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`lintStaged should load config file when specified 1`] = `
+"
+LOG 
+Running lint-staged with the following config:
+{
+	verbose: true,
+	linters: {
+		'*': 'mytask'
+	},
+	concurrent: true,
+	chunkSize: 9007199254740991,
+	gitDir: '.',
+	globOptions: {
+		matchBase: true,
+		dot: true
+	},
+	subTaskConcurrency: 1,
+	renderer: 'verbose'
+}
+"
+`;
+
 exports[`lintStaged should not output config in non verbose mode 1`] = `""`;
 
-exports[`lintStaged should ouput config in verbose mode 1`] = `
+exports[`lintStaged should output config in verbose mode 1`] = `
 "
 LOG 
 Running lint-staged with the following config:
@@ -32,3 +54,14 @@ Please make sure you have created it correctly.
 See https://github.com/okonet/lint-staged#configuration.
 "
 `;
+
+exports[`lintStaged should print helpful error message when explicit config file is not found 1`] = `
+
+ERROR Could not parse lint-staged config.
+
+Error: ENOENT: no such file or directory, open 'fake-config-file.yml'
+ERROR 
+Please make sure you have created it correctly.
+See https://github.com/okonet/lint-staged#configuration.
+
+`;
diff --git a/test/index.spec.js b/test/index.spec.js
index 1a4f9d5..f73388a 100644
--- a/test/index.spec.js
+++ b/test/index.spec.js
@@ -1,15 +1,23 @@
 import { makeConsoleMock } from 'consolemock'
 import cosmiconfig from 'cosmiconfig'
+import path from 'path'
 import lintStaged from '../src/index'
 
+const replaceSerializer = (from, to) => ({
+  test: val => typeof val === 'string' && from.test(val),
+  print: val => val.replace(from, to)
+})
+
 jest.mock('cosmiconfig')
 
 describe('lintStaged', () => {
+  let logger
+
   beforeEach(() => {
-    console = makeConsoleMock()
+    logger = makeConsoleMock()
   })
 
-  it('should ouput config in verbose mode', async () => {
+  it('should output config in verbose mode', async () => {
     const config = {
       verbose: true,
       linters: {
@@ -17,8 +25,8 @@ describe('lintStaged', () => {
       }
     }
     cosmiconfig.mockImplementationOnce(() => Promise.resolve({ config }))
-    await lintStaged()
-    expect(console.printHistory()).toMatchSnapshot()
+    await lintStaged(logger)
+    expect(logger.printHistory()).toMatchSnapshot()
   })
 
   it('should not output config in non verbose mode', async () => {
@@ -26,13 +34,33 @@ describe('lintStaged', () => {
       '*': 'mytask'
     }
     cosmiconfig.mockImplementationOnce(() => Promise.resolve({ config }))
-    await lintStaged()
-    expect(console.printHistory()).toMatchSnapshot()
+    await lintStaged(logger)
+    expect(logger.printHistory()).toMatchSnapshot()
+  })
+
+  it('should load config file when specified', async () => {
+    await lintStaged(logger, path.join(__dirname, '__mocks__', 'my-config.json'))
+    expect(logger.printHistory()).toMatchSnapshot()
   })
 
   it('should print helpful error message when config file is not found', async () => {
     cosmiconfig.mockImplementationOnce(() => Promise.resolve(null))
-    await lintStaged()
-    expect(console.printHistory()).toMatchSnapshot()
+    await lintStaged(logger)
+    expect(logger.printHistory()).toMatchSnapshot()
+  })
+
+  it('should print helpful error message when explicit config file is not found', async () => {
+    const nonExistentConfig = 'fake-config-file.yml'
+
+    // Serialize Windows, Linux and MacOS paths consistently
+    expect.addSnapshotSerializer(
+      replaceSerializer(
+        /Error: ENOENT: no such file or directory, open '([^']+)'/,
+        `Error: ENOENT: no such file or directory, open '${nonExistentConfig}'`
+      )
+    )
+
+    await lintStaged(logger, nonExistentConfig)
+    expect(logger.printHistory()).toMatchSnapshot()
   })
 })