Repository: okonet/lint-staged
Pull request #141 (2017-03-17 16:17:45)

Feature: Config validation
This PR implements config validation 🎉

![2017-09-01 at 15 23](https://user-images.githubusercontent.com/11071/29971716-93e614fc-8f29-11e7-94a1-def15a6eebe6.png)


### Why

Invalid config is a constant source of issues for this library. To reduce the amount of such tickets and to make deprecations easier, I've introduced `jest-validate` to this repository. Additionally, lots of refactoring and additional tests were added in the process.

### Previous scope:

~~This PR refactors the code in a way it's more testable and adds tests for uncovered parts. One big change is the `getConfig` function that is solely concerned about how configuration is done. This is the place where we will add config validation in the future.~~

~~The coverage went down because of this PR but in reality it should have been increased. The `runAll` function has least coverage right now and this is where I want to spend some time after this is merged.~~

codecov[bot]: # [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/141?src=pr&el=h1) Report
> Merging [#141](https://codecov.io/gh/okonet/lint-staged/pull/141?src=pr&el=desc) into [master](https://codecov.io/gh/okonet/lint-staged/commit/b0204ee4fc971a4099e6145bbd65dab188f0d081?src=pr&el=desc) will **decrease** coverage by `6.21%`.
> The diff coverage is `79.16%`.


```diff
@@            Coverage Diff             @@
##           master     #141      +/-   ##
==========================================
- Coverage   89.74%   83.52%   -6.22%     
==========================================
  Files           3        6       +3     
  Lines          39       85      +46     
  Branches        5       14       +9     
==========================================
+ Hits           35       71      +36     
- Misses          4       14      +10
```


| [Impacted Files](https://codecov.io/gh/okonet/lint-staged/pull/141?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/generateTasks.js](https://codecov.io/gh/okonet/lint-staged/compare/b0204ee4fc971a4099e6145bbd65dab188f0d081...451cabdddb4eff46065fe5d88c5d4683b7c0a336?src=pr&el=tree#diff-c3JjL2dlbmVyYXRlVGFza3MuanM=) | `100% <100%> (ø)` | :white_check_mark: |
| [src/resolveGitDir.js](https://codecov.io/gh/okonet/lint-staged/compare/b0204ee4fc971a4099e6145bbd65dab188f0d081...451cabdddb4eff46065fe5d88c5d4683b7c0a336?src=pr&el=tree#diff-c3JjL3Jlc29sdmVHaXREaXIuanM=) | `100% <100%> (ø)` | |
| [src/getConfig.js](https://codecov.io/gh/okonet/lint-staged/compare/b0204ee4fc971a4099e6145bbd65dab188f0d081...451cabdddb4eff46065fe5d88c5d4683b7c0a336?src=pr&el=tree#diff-c3JjL2dldENvbmZpZy5qcw==) | `100% <100%> (ø)` | |
| [src/runAll.js](https://codecov.io/gh/okonet/lint-staged/compare/b0204ee4fc971a4099e6145bbd65dab188f0d081...451cabdddb4eff46065fe5d88c5d4683b7c0a336?src=pr&el=tree#diff-c3JjL3J1bkFsbC5qcw==) | `68.75% <68.75%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/okonet/lint-staged/pull/141?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/141?src=pr&el=footer). Last update [b0204ee...451cabd](https://codecov.io/gh/okonet/lint-staged/compare/b0204ee4fc971a4099e6145bbd65dab188f0d081...451cabdddb4eff46065fe5d88c5d4683b7c0a336?el=footer&src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).
okonet: It stopped working for me without it...

Sincerely yours

Andrey Okonetchnikov

@okonetchnikov
http://okonet.ru

On 30 August 2017 at 20:18:48, Lufty Wiranda (notifications@github.com)
wrote:

> *@luftywiranda13* commented on this pull request.
> ------------------------------
>
> In package.json
> <https://github.com/okonet/lint-staged/pull/141#discussion_r136150058>:
>
> > @@ -77,6 +77,7 @@
>      "expect": "^1.20.2",
>      "is-promise": "^2.1.0",
>      "jest": "^20.0.1",
> +    "jest-cli": "^20.0.4",
>
> Do we really need this? because jest-cli already exposed by the main jest
> package
>
> https://github.com/facebook/jest/blob/master/packages/jest/package.json
>
>   "dependencies": {
>     "jest-cli": "^20.0.4"
>   },
>   "bin": {
>     "jest": "./bin/jest.js"
>   }
>
> https://github.com/facebook/jest/blob/master/packages/jest/bin/jest.js
>
> require('jest-cli/bin/jest');
>
> —
> You are receiving this because you authored the thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/okonet/lint-staged/pull/141#pullrequestreview-59648734>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AAArP355DZZj7OugHLxJAyhMsZqd5s6aks5sdaeIgaJpZM4MgzGk>
> .
>

sudo-suhas: Failed tests report on windows:
```
 FAIL  test\resolveGitDir.spec.js
  ● resolveGitDir › should resolve to absolute dir from config

    expect(received).toEqual(expected)

    Expected value to equal:
      "/path/to/some/dir"
    Received:
      "E:\\path\\to\\some\\dir"

      at Object.<anonymous> (test/resolveGitDir.spec.js:21:63)
          at Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)
          at <anonymous>

 FAIL  test\generateTasks.spec.js
  ● generateTasks › should match pattern "*.js" for relative path

    expect(received).toEqual(expected)

    Expected value to equal:
      {"commands": "root-js", "fileList": ["E:\\Projects\\repos/test.js", "E:\\Projects\\repos/deeper/test.js", "E:\\Projects\\repos/deeper/test2.js", "E:\\Projects\\repos/even/deeper/test.js", "E:\\Projects\\repos/.hidden/test.js"], "pattern": "*.js"}
    Received:
      {"commands": "root-js", "fileList": ["E:\\Projects\\repos\\test.js", "E:\\Projects\\repos\\deeper\\test.js", "E:\\Projects\\repos\\deeper\\test2.js", "E:\\Projects\\repos\\even\\deeper\\test.js", "E:\\Projects\\repos\\.hidden\\test.js"], "pattern": "*.js"}

    Difference:

    - Expected
    + Received

    Object {
       "commands": "root-js",
       "fileList": Array [
    -    "E:\\Projects\\repos/test.js",
    -    "E:\\Projects\\repos/deeper/test.js",
    -    "E:\\Projects\\repos/deeper/test2.js",
    -    "E:\\Projects\\repos/even/deeper/test.js",
    -    "E:\\Projects\\repos/.hidden/test.js",
    +    "E:\\Projects\\repos\\test.js",
    +    "E:\\Projects\\repos\\deeper\\test.js",
    +    "E:\\Projects\\repos\\deeper\\test2.js",
    +    "E:\\Projects\\repos\\even\\deeper\\test.js",
    +    "E:\\Projects\\repos\\.hidden\\test.js",
       ],
       "pattern": "*.js",
     }

      at Object.<anonymous> (test/generateTasks.spec.js:92:20)
          at Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)
          at <anonymous>

  ● generateTasks › should match pattern "*.js"

    expect(received).toEqual(expected)

    Expected value to equal:
      {"commands": "root-js", "fileList": ["/root/test.js", "/root/deeper/test.js", "/root/deeper/test2.js", "/root/even/deeper/test.js", "/root/.hidden/test.js"], "pattern": "*.js"}
    Received:
      {"commands": "root-js", "fileList": ["E:\\root\\test.js", "E:\\root\\deeper\\test.js", "E:\\root\\deeper\\test2.js", "E:\\root\\even\\deeper\\test.js", "E:\\root\\.hidden\\test.js"], "pattern": "*.js"}

    Difference:

    - Expected
    + Received

    Object {
       "commands": "root-js",
       "fileList": Array [
    -    "/root/test.js",
    -    "/root/deeper/test.js",
    -    "/root/deeper/test2.js",
    -    "/root/even/deeper/test.js",
    -    "/root/.hidden/test.js",
    +    "E:\\root\\test.js",
    +    "E:\\root\\deeper\\test.js",
    +    "E:\\root\\deeper\\test2.js",
    +    "E:\\root\\even\\deeper\\test.js",
    +    "E:\\root\\.hidden\\test.js",
       ],
       "pattern": "*.js",
     }

      at Object.<anonymous> (test/generateTasks.spec.js:108:20)
          at Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)
          at <anonymous>

  ● generateTasks › should match pattern "**/*.js"

    expect(received).toEqual(expected)

    Expected value to equal:
      {"commands": "any-js", "fileList": ["/root/test.js", "/root/deeper/test.js", "/root/deeper/test2.js", "/root/even/deeper/test.js", "/root/.hidden/test.js"], "pattern": "**/*.js"}
    Received:
      {"commands": "any-js", "fileList": ["E:\\root\\test.js", "E:\\root\\deeper\\test.js", "E:\\root\\deeper\\test2.js", "E:\\root\\even\\deeper\\test.js", "E:\\root\\.hidden\\test.js"], "pattern": "**/*.js"}

    Difference:

    - Expected
    + Received

    Object {
       "commands": "any-js",
       "fileList": Array [
    -    "/root/test.js",
    -    "/root/deeper/test.js",
    -    "/root/deeper/test2.js",
    -    "/root/even/deeper/test.js",
    -    "/root/.hidden/test.js",
    +    "E:\\root\\test.js",
    +    "E:\\root\\deeper\\test.js",
    +    "E:\\root\\deeper\\test2.js",
    +    "E:\\root\\even\\deeper\\test.js",
    +    "E:\\root\\.hidden\\test.js",
       ],
       "pattern": "**/*.js",
     }

      at Object.<anonymous> (test/generateTasks.spec.js:124:20)
          at Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)
          at <anonymous>

  ● generateTasks › should match pattern "deeper/*.js"

    expect(received).toEqual(expected)

    Expected value to equal:
      {"commands": "deeper-js", "fileList": ["/root/deeper/test.js", "/root/deeper/test2.js"], "pattern": "deeper/*.js"}
    Received:
      {"commands": "deeper-js", "fileList": ["E:\\root\\deeper\\test.js", "E:\\root\\deeper\\test2.js"], "pattern": "deeper/*.js"}

    Difference:

    - Expected
    + Received

    Object {
       "commands": "deeper-js",
       "fileList": Array [
    -    "/root/deeper/test.js",
    -    "/root/deeper/test2.js",
    +    "E:\\root\\deeper\\test.js",
    +    "E:\\root\\deeper\\test2.js",
       ],
       "pattern": "deeper/*.js",
     }

      at Object.<anonymous> (test/generateTasks.spec.js:140:20)
          at Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)
          at <anonymous>

  ● generateTasks › should match pattern ".hidden/*.js"

    expect(received).toEqual(expected)

    Expected value to equal:
      {"commands": "hidden-js", "fileList": ["/root/.hidden/test.js"], "pattern": ".hidden/*.js"}
    Received:
      {"commands": "hidden-js", "fileList": ["E:\\root\\.hidden\\test.js"], "pattern": ".hidden/*.js"}

    Difference:

    - Expected
    + Received

    Object {
       "commands": "hidden-js",
       "fileList": Array [
    -    "/root/.hidden/test.js",
    +    "E:\\root\\.hidden\\test.js",
       ],
       "pattern": ".hidden/*.js",
     }

      at Object.<anonymous> (test/generateTasks.spec.js:150:20)
          at Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)
          at <anonymous>

  ● generateTasks › should match pattern "*.{css,js}"

    expect(received).toEqual(expected)

    Expected value to equal:
      {"commands": "root-css-or-js", "fileList": ["/root/test.js", "/root/deeper/test.js", "/root/deeper/test2.js", "/root/even/deeper/test.js", "/root/.hidden/test.js", "/root/test.css", "/root/deeper/test.css", "/root/deeper/test2.css", "/root/even/deeper/test.css", "/root/.hidden/test.css"], "pattern": "*.{css,js}"}
    Received:
      {"commands": "root-css-or-js", "fileList": ["E:\\root\\test.js", "E:\\root\\deeper\\test.js", "E:\\root\\deeper\\test2.js", "E:\\root\\even\\deeper\\test.js", "E:\\root\\.hidden\\test.js", "E:\\root\\test.css", "E:\\root\\deeper\\test.css", "E:\\root\\deeper\\test2.css", "E:\\root\\even\\deeper\\test.css", "E:\\root\\.hidden\\test.css"], "pattern": "*.{css,js}"}

    Difference:

    - Expected
    + Received

    Object {
       "commands": "root-css-or-js",
       "fileList": Array [
    -    "/root/test.js",
    -    "/root/deeper/test.js",
    -    "/root/deeper/test2.js",
    -    "/root/even/deeper/test.js",
    -    "/root/.hidden/test.js",
    -    "/root/test.css",
    -    "/root/deeper/test.css",
    -    "/root/deeper/test2.css",
    -    "/root/even/deeper/test.css",
    -    "/root/.hidden/test.css",
    +    "E:\\root\\test.js",
    +    "E:\\root\\deeper\\test.js",
    +    "E:\\root\\deeper\\test2.js",
    +    "E:\\root\\even\\deeper\\test.js",
    +    "E:\\root\\.hidden\\test.js",
    +    "E:\\root\\test.css",
    +    "E:\\root\\deeper\\test.css",
    +    "E:\\root\\deeper\\test2.css",
    +    "E:\\root\\even\\deeper\\test.css",
    +    "E:\\root\\.hidden\\test.css",
       ],
       "pattern": "*.{css,js}",
     }

      at Object.<anonymous> (test/generateTasks.spec.js:160:20)
          at Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)
          at <anonymous>

  ● generateTasks › should support globOptions specified in advanced configuration

    expect(received).toEqual(expected)

    Expected value to equal:
      {"commands": "lint", "fileList": ["/root/test.js", "/root/test.css", "/root/test.txt"], "pattern": "TeSt.*"}
    Received:
      {"commands": "lint", "fileList": ["E:\\root\\test.js", "E:\\root\\test.css", "E:\\root\\test.txt"], "pattern": "TeSt.*"}

    Difference:

    - Expected
    + Received

    Object {
       "commands": "lint",
       "fileList": Array [
    -    "/root/test.js",
    -    "/root/test.css",
    -    "/root/test.txt",
    +    "E:\\root\\test.js",
    +    "E:\\root\\test.css",
    +    "E:\\root\\test.txt",
       ],
       "pattern": "TeSt.*",
     }

      at Object.<anonymous> (test/generateTasks.spec.js:193:20)
          at Promise (<anonymous>)
      at Promise.resolve.then.el (node_modules/p-map/index.js:42:16)
          at <anonymous>
``` 
sudo-suhas: Since you have the full lodash module, why not setup [eslint-plugin-lodash](https://github.com/wix/eslint-plugin-lodash)? That can suggest few changes like using [`lodash#has`](https://lodash.com/docs/4.17.4#has) instead of `hasOwnProperty`.
okonet: Good idea. Do you want tackle this in a separate PR?
sudo-suhas: Sure. I'll make a PR with that after this is merged in?
okonet: @sudo-suhas @luftywiranda13 updated the branch. Another round of review please?
okonet: @sudo-suhas @luftywiranda13 I've added the validation to this PR and it works great IMO. Could you please give it a try and take another look over the latest changes. I'd like to merge it ASAP.
sudo-suhas: I am actually out of town for the next couple of days.. I'll try nevertheless. 
okonet: @luftywiranda13 @sudo-suhas I've updated the branch. I'd like to merge it today. Thanks for reviews. That was very valuable feedback. Final "approve", please?

Patch:
diff --git a/index.js b/index.js
index 6fedcbb..0df45de 100755
--- a/index.js
+++ b/index.js
@@ -1,2 +1,2 @@
 #!/usr/bin/env node
-require('./src')
+require('./src')()
diff --git a/package.json b/package.json
index 0319747..a18a513 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,8 @@
   },
   "pre-commit": "pre-commit",
   "jest": {
-    "testEnvironment": "node"
+    "testEnvironment": "node",
+    "setupFiles": ["./testSetup.js"]
   },
   "greenkeeper": {
     "ignore": [
@@ -55,31 +56,36 @@
   "homepage": "https://github.com/okonet/lint-staged#readme",
   "dependencies": {
     "app-root-path": "^2.0.0",
+    "chalk": "^2.1.0",
     "cosmiconfig": "^1.1.0",
     "execa": "^0.8.0",
+    "is-glob": "^4.0.0",
+    "jest-validate": "^20.0.3",
     "listr": "^0.12.0",
-    "lodash.chunk": "^4.2.0",
+    "lodash": "^4.17.4",
     "minimatch": "^3.0.0",
     "npm-which": "^3.0.1",
     "p-map": "^1.1.1",
-    "staged-git-files": "0.0.4"
+    "staged-git-files": "0.0.4",
+    "stringify-object": "^3.2.0"
   },
   "devDependencies": {
     "babel-jest": "^20.0.0",
     "babel-preset-env": "^1.6.0",
     "commitizen": "^2.9.6",
+    "consolemock": "^0.2.1",
     "cz-conventional-changelog": "^2.0.0",
     "eslint": "^4.5.0",
     "eslint-config-okonet": "^5.0.1",
     "eslint-plugin-node": "^5.1.1",
-    "expect": "^1.20.2",
-    "is-promise": "^2.1.0",
-    "jest": "^20.0.1",
+    "jest": "^20.0.4",
+    "jest-cli": "^20.0.4",
     "jsonlint": "^1.6.2",
     "npm-check": "^5.2.2",
     "pre-commit": "^1.1.3",
     "prettier": "1.5.3",
-    "remove-lockfiles": "^1.1.1"
+    "remove-lockfiles": "^1.1.1",
+    "strip-ansi": "^3.0.1"
   },
   "config": {
     "commitizen": {
diff --git a/src/generateTasks.js b/src/generateTasks.js
index cf462a9..ab1390f 100644
--- a/src/generateTasks.js
+++ b/src/generateTasks.js
@@ -1,26 +1,23 @@
 'use strict'
 
+const path = require('path')
 const minimatch = require('minimatch')
-
-const readConfigOption = require('./readConfigOption')
+const getConfig = require('./getConfig').getConfig
+const resolveGitDir = require('./resolveGitDir')
 
 module.exports = function generateTasks(config, files) {
-  const linters = config.linters !== undefined ? config.linters : config
-  const resolve = file => files[file]
+  const normalizedConfig = getConfig(config) // Ensure we have a normalized config
+  const linters = normalizedConfig.linters
+  const gitDir = normalizedConfig.gitDir
+  const globOptions = normalizedConfig.globOptions
   return Object.keys(linters).map(pattern => {
     const commands = linters[pattern]
-    const globOptions = readConfigOption(config, 'globOptions', {})
-    const filter = minimatch.filter(
-      pattern,
-      Object.assign(
-        {
-          matchBase: true,
-          dot: true
-        },
-        globOptions
-      )
-    )
-    const fileList = Object.keys(files).filter(filter).map(resolve)
+    const filter = minimatch.filter(pattern, globOptions)
+    const fileList = files
+      // We want to filter before resolving paths
+      .filter(filter)
+      // Return absolute path after the filter is run
+      .map(file => path.resolve(resolveGitDir(gitDir), file))
     return {
       pattern,
       commands,
diff --git a/src/getConfig.js b/src/getConfig.js
new file mode 100644
index 0000000..1f0467d
--- /dev/null
+++ b/src/getConfig.js
@@ -0,0 +1,137 @@
+/* eslint no-console: 0 */
+/* eslint no-prototype-builtins: 0 */
+const chalk = require('chalk')
+const format = require('stringify-object')
+const intersection = require('lodash/intersection')
+const defaultsDeep = require('lodash/defaultsDeep')
+const isObject = require('lodash/isObject')
+const validate = require('jest-validate').validate
+const logValidationWarning = require('jest-validate').logValidationWarning
+const unknownOptionWarning = require('jest-validate/build/warnings').unknownOptionWarning
+const isGlob = require('is-glob')
+
+/**
+ * Default config object
+ *
+ * @type {{concurrent: boolean, chunkSize: number, gitDir: string, globOptions: {matchBase: boolean, dot: boolean}, linters: {}, subTaskConcurrency: number, renderer: string, verbose: boolean}}
+ */
+const defaultConfig = {
+  concurrent: true,
+  chunkSize: Number.MAX_SAFE_INTEGER,
+  gitDir: '.',
+  globOptions: {
+    matchBase: true,
+    dot: true
+  },
+  linters: {},
+  subTaskConcurrency: 1,
+  renderer: 'update',
+  verbose: false
+}
+
+/**
+ * Check if the config is "simple" i.e. doesn't contains any of full config keys
+ *
+ * @param config
+ * @returns {boolean}
+ */
+function isSimple(config) {
+  return (
+    isObject(config) &&
+    !config.hasOwnProperty('linters') &&
+    intersection(Object.keys(defaultConfig), Object.keys(config)).length === 0
+  )
+}
+
+/**
+ * Custom jest-validate reporter for unknown options
+ * @param config
+ * @param example
+ * @param option
+ * @param options
+ * @returns {void}
+ */
+function unknownValidationReporter(config, example, option, options) {
+  /**
+   * If the unkonwn property is a glob this is probably
+   * a typical mistake of mixing simple and advanced configs
+   */
+  if (isGlob(option)) {
+    const message = `  Unknown option ${chalk.bold(`"${option}"`)} with value ${chalk.bold(
+      format(config[option], { inlineCharacterLimit: Number.POSITIVE_INFINITY })
+    )} was found in the config root.
+  
+  You are probably trying to mix simple and advanced config formats. Adding 
+  
+  ${chalk.bold(`"linters": {
+    "${option}": ${JSON.stringify(config[option])}
+  }`)}
+   
+  will fix it and remove this message.`
+
+    const comment = options.comment
+    const name = (options.title && options.title.warning) || 'WARNING'
+    return logValidationWarning(name, message, comment)
+  }
+  // If it is not glob pattern, use default jest-validate reporter
+  return unknownOptionWarning(config, example, option, options)
+}
+
+/**
+ * For a given configuration object that we retrive from .lintstagedrc or package.json
+ * construct a full configuration with all options set.
+ *
+ * This is a bit tricky since we support 2 different syntxes: simple and full
+ * For simple config, only the `linters` configuration is provided.
+ *
+ * @param {Object} sourceConfig
+ * @returns {{
+ *  concurrent: boolean, chunkSize: number, gitDir: string, globOptions: {matchBase: boolean, dot: boolean}, linters: {}, subTaskConcurrency: number, renderer: string, verbose: boolean
+ * }}
+ */
+function getConfig(sourceConfig) {
+  const config = defaultsDeep(
+    {}, // Do not mutate sourceConfig!!!
+    isSimple(sourceConfig) ? { linters: sourceConfig } : sourceConfig,
+    defaultConfig
+  )
+
+  // Check if renderer is set in sourceConfig and if not, set accordingly to verbose
+  if (isObject(sourceConfig) && !sourceConfig.hasOwnProperty('renderer')) {
+    config.renderer = config.verbose ? 'verbose' : 'update'
+  }
+
+  return config
+}
+
+/**
+ * Runs config validation. Throws error if the config is not valid.
+ * @param config {Object}
+ * @returns config {Object}
+ */
+function validateConfig(config) {
+  const exampleConfig = Object.assign({}, defaultConfig, {
+    linters: {
+      '*.js': ['eslint --fix', 'git add'],
+      '*.css': 'stylelint'
+    }
+  })
+
+  const validation = validate(config, {
+    exampleConfig,
+    unknown: unknownValidationReporter,
+    comment:
+      'Please refer to https://github.com/okonet/lint-staged#configuration for more information...'
+  })
+
+  if (!validation.isValid) {
+    throw new Error('lint-staged config is invalid... Aborting.')
+  }
+
+  return config
+}
+
+module.exports = {
+  getConfig,
+  validateConfig
+}
diff --git a/src/index.js b/src/index.js
index b857fa5..26067c9 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,18 +1,19 @@
 /* eslint no-console: 0 */
-/* eslint no-process-exit: 0 */
+/* eslint import/no-dynamic-require: 0 */
 
 'use strict'
 
-const path = require('path')
-const sgf = require('staged-git-files')
 const appRoot = require('app-root-path')
-const Listr = require('listr')
 const cosmiconfig = require('cosmiconfig')
+const stringifyObject = require('stringify-object')
+const getConfig = require('./getConfig').getConfig
+const validateConfig = require('./getConfig').validateConfig
+const printErrors = require('./printErrors')
+const runAll = require('./runAll')
 
-const packageJson = require(appRoot.resolve('package.json')); // eslint-disable-line
-const runScript = require('./runScript')
-const generateTasks = require('./generateTasks')
-const readConfigOption = require('./readConfigOption')
+// Find the right package.json at the root of the project
+// TODO: Test if it should be aware of `gitDir`
+const packageJson = require(appRoot.resolve('package.json'))
 
 // Force colors for packages that depend on https://www.npmjs.com/package/supports-color
 // but do this only in TTY mode
@@ -20,85 +21,43 @@ if (process.stdout.isTTY) {
   process.env.FORCE_COLOR = true
 }
 
-cosmiconfig('lint-staged', {
-  rc: '.lintstagedrc',
-  rcExtensions: true
-})
-  .then(result => {
-    // result.config is the parsed configuration object
-    // result.filepath is the path to the config file that was found
-    const config = result.config
-
-    const verbose = config.verbose
-    // Output config in verbose mode
-    if (verbose) console.log(config)
-    const concurrent = readConfigOption(config, 'concurrent', true)
-    const renderer = verbose ? 'verbose' : 'update'
-    const gitDir = config.gitDir ? path.resolve(config.gitDir) : process.cwd()
-    sgf.cwd = gitDir
-
-    sgf('ACM', (err, files) => {
-      if (err) {
-        console.error(err)
-        process.exit(1)
+/**
+ * Root lint-staged function that is called from .bin
+ */
+module.exports = function lintStaged() {
+  cosmiconfig('lint-staged', {
+    rc: '.lintstagedrc',
+    rcExtensions: true
+  })
+    .then(result => {
+      // result.config is the parsed configuration object
+      // result.filepath is the path to the config file that was found
+      const config = validateConfig(getConfig(result.config))
+
+      if (config.verbose) {
+        console.log(`
+Running lint-staged with the following config:
+${stringifyObject(config)}
+`)
       }
 
-      const resolvedFiles = {}
-      files.forEach(file => {
-        const absolute = path.resolve(gitDir, file.filename)
-        const relative = path.relative(gitDir, absolute)
-        resolvedFiles[relative] = absolute
-      })
-
-      const tasks = generateTasks(config, resolvedFiles).map(task => ({
-        title: `Running tasks for ${task.pattern}`,
-        task: () =>
-          new Listr(
-            runScript(task.commands, task.fileList, packageJson, {
-              gitDir,
-              verbose,
-              config
-            }),
-            {
-              // In sub-tasks we don't want to run concurrently
-              // and we want to abort on errors
-              concurrent: false,
-              exitOnError: true
-            }
-          ),
-        skip: () => {
-          if (task.fileList.length === 0) {
-            return `No staged files match ${task.pattern}`
-          }
-          return false
-        }
-      }))
-
-      if (tasks.length) {
-        new Listr(tasks, {
-          concurrent,
-          renderer,
-          exitOnError: !concurrent // Wait for all errors when running concurrently
+      runAll(packageJson, config)
+        .then(() => {
+          // No errors, exiting with 0
+          process.exitCode = 0
+        })
+        .catch(error => {
+          // Errors detected, printing and exiting with non-zero
+          printErrors(error)
+          process.exitCode = 1
         })
-          .run()
-          .catch(error => {
-            if (Array.isArray(error.errors)) {
-              error.errors.forEach(lintError => {
-                console.error(lintError.message)
-              })
-            } else {
-              console.log(error.message)
-            }
-            process.exit(1)
-          })
-      }
     })
-  })
-  .catch(parsingError => {
-    console.error(`Could not parse lint-staged config.
+    .catch(parsingError => {
+      console.error(`Could not parse lint-staged config.
 Make sure you have created it. See https://github.com/okonet/lint-staged#readme.
 
 ${parsingError}
 `)
-    process.exit(1)
-  })
+      process.exitCode = 1
+    })
+}
diff --git a/src/printErrors.js b/src/printErrors.js
new file mode 100644
index 0000000..df395e1
--- /dev/null
+++ b/src/printErrors.js
@@ -0,0 +1,13 @@
+/* eslint no-console: 0 */
+
+'use strict'
+
+module.exports = function printErrors(errorInstance) {
+  if (Array.isArray(errorInstance.errors)) {
+    errorInstance.errors.forEach(lintError => {
+      console.error(lintError.message)
+    })
+  } else {
+    console.error(errorInstance.message)
+  }
+}
diff --git a/src/readConfigOption.js b/src/readConfigOption.js
deleted file mode 100644
index 79d7a38..0000000
--- a/src/readConfigOption.js
+++ /dev/null
@@ -1,19 +0,0 @@
-'use strict'
-
-/**
- * Helper function for reading config option.
- * Returns the `config` option for given `key` or the given `defaultValue`
- * if `config` does not have the given `key`.
- *
- * @param {Object} config
- * @param {string} key
- * @param {*} defaultValue
- * @returns {*}
- */
-module.exports = function readConfigOption(config, key, defaultValue) {
-  if (typeof config !== 'undefined' && typeof config[key] !== 'undefined') {
-    return config[key]
-  }
-
-  return defaultValue
-}
diff --git a/src/resolveGitDir.js b/src/resolveGitDir.js
new file mode 100644
index 0000000..68371e8
--- /dev/null
+++ b/src/resolveGitDir.js
@@ -0,0 +1,5 @@
+const path = require('path')
+
+module.exports = function resolveGitDir(gitDir) {
+  return gitDir ? path.resolve(gitDir) : process.cwd()
+}
diff --git a/src/runAll.js b/src/runAll.js
new file mode 100644
index 0000000..a9f331e
--- /dev/null
+++ b/src/runAll.js
@@ -0,0 +1,65 @@
+'use strict'
+
+const sgf = require('staged-git-files')
+const Listr = require('listr')
+const runScript = require('./runScript')
+const generateTasks = require('./generateTasks')
+const resolveGitDir = require('./resolveGitDir')
+
+/**
+ * Executes all tasks and either resolves or rejects the promise
+ * @param packageJson
+ * @param config
+ * @returns {Promise}
+ */
+module.exports = function runAll(packageJson, config) {
+  // Config validation
+  if (!config || !config.gitDir || !config.concurrent || !config.renderer) {
+    throw new Error('Invalid config provided to runAll! Use getConfig instead.')
+  }
+
+  const gitDir = config.gitDir
+  const concurrent = config.concurrent
+  const renderer = config.renderer
+  sgf.cwd = resolveGitDir(gitDir)
+
+  return new Promise((resolve, reject) => {
+    sgf('ACM', (err, files) => {
+      if (err) {
+        reject(err)
+      }
+
+      /* files is an Object{ filename: String, status: String } */
+      const filenames = files.map(file => file.filename)
+      const tasks = generateTasks(config, filenames).map(task => ({
+        title: `Running tasks for ${task.pattern}`,
+        task: () =>
+          new Listr(runScript(task.commands, task.fileList, packageJson, config), {
+            // In sub-tasks we don't want to run concurrently
+            // and we want to abort on errors
+            concurrent: false,
+            exitOnError: true
+          }),
+        skip: () => {
+          if (task.fileList.length === 0) {
+            return `No staged files match ${task.pattern}`
+          }
+          return false
+        }
+      }))
+
+      if (tasks.length) {
+        new Listr(tasks, {
+          concurrent,
+          renderer,
+          exitOnError: !concurrent // Wait for all errors when running concurrently
+        })
+          .run()
+          .then(resolve)
+          .catch(reject)
+      } else {
+        resolve('No tasks to run.')
+      }
+    })
+  })
+}
diff --git a/src/runScript.js b/src/runScript.js
index 0732992..53c204d 100644
--- a/src/runScript.js
+++ b/src/runScript.js
@@ -1,23 +1,19 @@
 'use strict'
 
-const chunk = require('lodash.chunk')
+const chunk = require('lodash/chunk')
 const execa = require('execa')
 const pMap = require('p-map')
-
+const getConfig = require('./getConfig').getConfig
 const calcChunkSize = require('./calcChunkSize')
 const findBin = require('./findBin')
-const readConfigOption = require('./readConfigOption')
-
-module.exports = function runScript(commands, pathsToLint, packageJson, options) {
-  const config = readConfigOption(options, 'config', {})
 
-  const concurrency = readConfigOption(config, 'subTaskConcurrency', 1)
-  const chunkSize = calcChunkSize(
-    pathsToLint,
-    readConfigOption(config, 'chunkSize', Number.MAX_SAFE_INTEGER)
-  )
+module.exports = function runScript(commands, pathsToLint, packageJson, config) {
+  const normalizedConfig = getConfig(config)
+  const chunkSize = normalizedConfig.chunkSize
+  const concurrency = normalizedConfig.subTaskConcurrency
+  const gitDir = normalizedConfig.gitDir
 
-  const filePathChunks = chunk(pathsToLint, chunkSize)
+  const filePathChunks = chunk(pathsToLint, calcChunkSize(pathsToLint, chunkSize))
 
   const lintersArray = Array.isArray(commands) ? commands : [commands]
 
@@ -25,14 +21,14 @@ module.exports = function runScript(commands, pathsToLint, packageJson, options)
     title: linter,
     task: () => {
       try {
-        const res = findBin(linter, packageJson, options)
+        const res = findBin(linter, packageJson, config)
 
         const separatorArgs = /npm(\.exe)?$/i.test(res.bin) ? ['--'] : []
 
         // Only use gitDir as CWD if we are using the git binary
         // e.g `npm` should run tasks in the actual CWD
         const execaOptions =
-          /git(\.exe)?$/i.test(res.bin) && options && options.gitDir ? { cwd: options.gitDir } : {}
+          /git(\.exe)?$/i.test(res.bin) && config && gitDir ? { cwd: gitDir } : {}
 
         const errors = []
         const mapper = pathsChunk => {


Test patch:
diff --git a/test/__snapshots__/getConfig.spec.js.snap b/test/__snapshots__/getConfig.spec.js.snap
new file mode 100644
index 0000000..83b6181
--- /dev/null
+++ b/test/__snapshots__/getConfig.spec.js.snap
@@ -0,0 +1,92 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`getConfig should return config with defaults 1`] = `
+Object {
+  "chunkSize": 9007199254740991,
+  "concurrent": true,
+  "gitDir": ".",
+  "globOptions": Object {
+    "dot": true,
+    "matchBase": true,
+  },
+  "linters": Object {},
+  "renderer": "update",
+  "subTaskConcurrency": 1,
+  "verbose": false,
+}
+`;
+
+exports[`getConfig should return config with defaults for undefined 1`] = `
+Object {
+  "chunkSize": 9007199254740991,
+  "concurrent": true,
+  "gitDir": ".",
+  "globOptions": Object {
+    "dot": true,
+    "matchBase": true,
+  },
+  "linters": Object {},
+  "renderer": "update",
+  "subTaskConcurrency": 1,
+  "verbose": false,
+}
+`;
+
+exports[`getConfig should set linters 1`] = `
+Object {
+  "chunkSize": 9007199254740991,
+  "concurrent": true,
+  "gitDir": ".",
+  "globOptions": Object {
+    "dot": true,
+    "matchBase": true,
+  },
+  "linters": Object {
+    "*.js": Array [
+      "eslint --fix",
+      "git add",
+    ],
+    ".*rc": "jsonlint",
+  },
+  "renderer": "update",
+  "subTaskConcurrency": 1,
+  "verbose": false,
+}
+`;
+
+exports[`validateConfig should not throw and should print nothing for advanced valid config 1`] = `""`;
+
+exports[`validateConfig should not throw and should print nothing for simple valid config 1`] = `""`;
+
+exports[`validateConfig should not throw and should print validation warnings for mixed config 1`] = `
+"
+WARN ● Validation Warning:
+
+  Unknown option \\"*.js\\" with value ['eslint --fix', 'git add'] was found in the config root.
+  
+  You are probably trying to mix simple and advanced config formats. Adding 
+  
+  \\"linters\\": {
+    \\"*.js\\": [\\"eslint --fix\\",\\"git add\\"]
+  }
+   
+  will fix it and remove this message.
+
+Please refer to https://github.com/okonet/lint-staged#configuration for more information..."
+`;
+
+exports[`validateConfig should throw and should print validation errors for invalid config 1`] = `
+"● Validation Error:
+
+  Option \\"chunkSize\\" must be of type:
+    number
+  but instead received:
+    string
+
+  Example:
+  {
+    \\"chunkSize\\": 9007199254740991
+  }
+
+Please refer to https://github.com/okonet/lint-staged#configuration for more information..."
+`;
diff --git a/test/__snapshots__/index.spec.js.snap b/test/__snapshots__/index.spec.js.snap
new file mode 100644
index 0000000..a217686
--- /dev/null
+++ b/test/__snapshots__/index.spec.js.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`lintStaged should not output config in non verbose mode 1`] = `""`;
+
+exports[`lintStaged should ouput config in verbose mode 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'
+}
+"
+`;
diff --git a/test/__snapshots__/printErrors.spec.js.snap b/test/__snapshots__/printErrors.spec.js.snap
new file mode 100644
index 0000000..b41d29d
--- /dev/null
+++ b/test/__snapshots__/printErrors.spec.js.snap
@@ -0,0 +1,12 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`printErrors should print Listr nested errors 1`] = `
+"
+ERROR Foo failed
+ERROR Unicorn failed"
+`;
+
+exports[`printErrors should print plain errors 1`] = `
+"
+ERROR We have a problem"
+`;
diff --git a/test/__snapshots__/runAll.spec.js.snap b/test/__snapshots__/runAll.spec.js.snap
new file mode 100644
index 0000000..61ac8c6
--- /dev/null
+++ b/test/__snapshots__/runAll.spec.js.snap
@@ -0,0 +1,5 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`runAll should throw when invalid config is provided 1`] = `"Invalid config provided to runAll! Use getConfig instead."`;
+
+exports[`runAll should throw when invalid config is provided 2`] = `"Invalid config provided to runAll! Use getConfig instead."`;
diff --git a/test/findBin.spec.js b/test/findBin.spec.js
index d3d9299..c9d1d71 100644
--- a/test/findBin.spec.js
+++ b/test/findBin.spec.js
@@ -1,4 +1,3 @@
-import expect from 'expect'
 import findBin from '../src/findBin'
 
 jest.mock('npm-which')
diff --git a/test/generateTasks.spec.js b/test/generateTasks.spec.js
index 7177840..fe7e318 100644
--- a/test/generateTasks.spec.js
+++ b/test/generateTasks.spec.js
@@ -1,33 +1,36 @@
-import expect from 'expect'
+import path from 'path'
 import generateTasks from '../src/generateTasks'
 
-const files = {
-  'test.js': '/root/test.js',
-  'deeper/test.js': '/root/deeper/test.js',
-  'deeper/test2.js': '/root/deeper/test2.js',
-  'even/deeper/test.js': '/root/even/deeper/test.js',
-  '.hidden/test.js': '/root/.hidden/test.js',
+const files = [
+  'test.js',
+  'deeper/test.js',
+  'deeper/test2.js',
+  'even/deeper/test.js',
+  '.hidden/test.js',
 
-  'test.css': '/root/test.css',
-  'deeper/test.css': '/root/deeper/test.css',
-  'deeper/test2.css': '/root/deeper/test2.css',
-  'even/deeper/test.css': '/root/even/deeper/test.css',
-  '.hidden/test.css': '/root/.hidden/test.css',
+  'test.css',
+  'deeper/test.css',
+  'deeper/test2.css',
+  'even/deeper/test.css',
+  '.hidden/test.css',
 
-  'test.txt': '/root/test.txt',
-  'deeper/test.txt': '/root/deeper/test.txt',
-  'deeper/test2.txt': '/root/deeper/test2.txt',
-  'even/deeper/test.txt': '/root/even/deeper/test.txt',
-  '.hidden/test.txt': '/root/.hidden/test.txt'
-}
+  'test.txt',
+  'deeper/test.txt',
+  'deeper/test2.txt',
+  'even/deeper/test.txt',
+  '.hidden/test.txt'
+]
 
-const linters = {
-  '*.js': 'root-js',
-  '**/*.js': 'any-js',
-  'deeper/*.js': 'deeper-js',
-  '.hidden/*.js': 'hidden-js',
-  'unknown/*.js': 'unknown-js',
-  '*.{css,js}': 'root-css-or-js'
+const config = {
+  gitDir: '/root',
+  linters: {
+    '*.js': 'root-js',
+    '**/*.js': 'any-js',
+    'deeper/*.js': 'deeper-js',
+    '.hidden/*.js': 'hidden-js',
+    'unknown/*.js': 'unknown-js',
+    '*.{css,js}': 'root-css-or-js'
+  }
 }
 
 describe('generateTasks', () => {
@@ -36,9 +39,7 @@ describe('generateTasks', () => {
       {
         '*.js': 'lint'
       },
-      {
-        'test.js': '/root/test.js'
-      }
+      ['test.js']
     )
     const commands = result.map(match => match.commands)
     expect(commands).toEqual(['lint'])
@@ -52,27 +53,57 @@ describe('generateTasks', () => {
           '*.js': 'lint'
         }
       },
-      {
-        'test.js': '/root/test.js'
-      }
+      ['test.js']
     )
     const commands = result.map(match => match.commands)
     expect(commands).toEqual(['lint'])
   })
 
+  it('should return absolute paths', () => {
+    const result = generateTasks(
+      {
+        gitDir: '..',
+        linters: {
+          '*': 'lint'
+        }
+      },
+      files
+    )
+    result[0].fileList.forEach(file => {
+      expect(path.isAbsolute(file)).toBe(true)
+    })
+  })
+
   it('should return an empty file list for linters with no matches.', () => {
-    const result = generateTasks(linters, files)
+    const result = generateTasks(config, files)
     result.forEach(task => {
       if (task.commands === 'unknown-js') {
         expect(task.fileList.length).toEqual(0)
       } else {
-        expect(task.fileList.length).toNotEqual(0)
+        expect(task.fileList.length).not.toEqual(0)
       }
     })
   })
 
+  it('should match pattern "*.js" for relative path', () => {
+    const relPath = path.resolve(path.join(process.cwd(), '..'))
+    const result = generateTasks(Object.assign({}, config, { gitDir: '..' }), files)
+    const linter = result.find(item => item.pattern === '*.js')
+    expect(linter).toEqual({
+      pattern: '*.js',
+      commands: 'root-js',
+      fileList: [
+        `${relPath}/test.js`,
+        `${relPath}/deeper/test.js`,
+        `${relPath}/deeper/test2.js`,
+        `${relPath}/even/deeper/test.js`,
+        `${relPath}/.hidden/test.js`
+      ]
+    })
+  })
+
   it('should match pattern "*.js"', () => {
-    const result = generateTasks(linters, files)
+    const result = generateTasks(config, files)
     const linter = result.find(item => item.pattern === '*.js')
     expect(linter).toEqual({
       pattern: '*.js',
@@ -88,7 +119,7 @@ describe('generateTasks', () => {
   })
 
   it('should match pattern "**/*.js"', () => {
-    const result = generateTasks(linters, files)
+    const result = generateTasks(config, files)
     const linter = result.find(item => item.pattern === '**/*.js')
     expect(linter).toEqual({
       pattern: '**/*.js',
@@ -104,7 +135,7 @@ describe('generateTasks', () => {
   })
 
   it('should match pattern "deeper/*.js"', () => {
-    const result = generateTasks(linters, files)
+    const result = generateTasks(config, files)
     const linter = result.find(item => item.pattern === 'deeper/*.js')
     expect(linter).toEqual({
       pattern: 'deeper/*.js',
@@ -114,7 +145,7 @@ describe('generateTasks', () => {
   })
 
   it('should match pattern ".hidden/*.js"', () => {
-    const result = generateTasks(linters, files)
+    const result = generateTasks(config, files)
     const linter = result.find(item => item.pattern === '.hidden/*.js')
     expect(linter).toEqual({
       pattern: '.hidden/*.js',
@@ -124,7 +155,7 @@ describe('generateTasks', () => {
   })
 
   it('should match pattern "*.{css,js}"', () => {
-    const result = generateTasks(linters, files)
+    const result = generateTasks(config, files)
     const linter = result.find(item => item.pattern === '*.{css,js}')
     expect(linter).toEqual({
       pattern: '*.{css,js}',
@@ -147,6 +178,7 @@ describe('generateTasks', () => {
   it('should support globOptions specified in advanced configuration', () => {
     const result = generateTasks(
       {
+        gitDir: '/root',
         globOptions: {
           matchBase: false,
           nocase: true
diff --git a/test/getConfig.spec.js b/test/getConfig.spec.js
new file mode 100644
index 0000000..3df7322
--- /dev/null
+++ b/test/getConfig.spec.js
@@ -0,0 +1,254 @@
+/* eslint no-console: 0 */
+
+import { makeConsoleMock } from 'consolemock'
+import { cloneDeep } from 'lodash'
+import { getConfig, validateConfig } from '../src/getConfig'
+
+describe('getConfig', () => {
+  it('should return config with defaults for undefined', () => {
+    expect(getConfig()).toMatchSnapshot()
+  })
+
+  it('should return config with defaults', () => {
+    expect(getConfig({})).toMatchSnapshot()
+  })
+
+  it('should set verbose', () => {
+    expect(getConfig({})).toEqual(
+      expect.objectContaining({
+        verbose: false
+      })
+    )
+
+    expect(
+      getConfig({
+        verbose: false
+      })
+    ).toEqual(
+      expect.objectContaining({
+        verbose: false
+      })
+    )
+
+    expect(
+      getConfig({
+        verbose: true
+      })
+    ).toEqual(
+      expect.objectContaining({
+        verbose: true
+      })
+    )
+  })
+
+  it('should set concurrent', () => {
+    expect(getConfig({})).toEqual(
+      expect.objectContaining({
+        concurrent: true
+      })
+    )
+
+    expect(
+      getConfig({
+        concurrent: false
+      })
+    ).toEqual(
+      expect.objectContaining({
+        concurrent: false
+      })
+    )
+
+    expect(
+      getConfig({
+        concurrent: true
+      })
+    ).toEqual(
+      expect.objectContaining({
+        concurrent: true
+      })
+    )
+  })
+
+  it('should set renderer based on verbose key', () => {
+    expect(getConfig({})).toEqual(
+      expect.objectContaining({
+        renderer: 'update'
+      })
+    )
+
+    expect(
+      getConfig({
+        '*.js': ['eslint', 'git add']
+      })
+    ).toEqual(
+      expect.objectContaining({
+        renderer: 'update'
+      })
+    )
+
+    expect(
+      getConfig({
+        verbose: false
+      })
+    ).toEqual(
+      expect.objectContaining({
+        renderer: 'update'
+      })
+    )
+
+    expect(
+      getConfig({
+        verbose: true
+      })
+    ).toEqual(
+      expect.objectContaining({
+        renderer: 'verbose'
+      })
+    )
+  })
+
+  it('should set gitDir', () => {
+    expect(getConfig({})).toEqual(
+      expect.objectContaining({
+        gitDir: '.'
+      })
+    )
+
+    expect(
+      getConfig({
+        gitDir: '../path'
+      })
+    ).toEqual(
+      expect.objectContaining({
+        gitDir: '../path'
+      })
+    )
+  })
+
+  it('should set linters', () => {
+    expect(getConfig()).toEqual(
+      expect.objectContaining({
+        linters: {}
+      })
+    )
+
+    expect(getConfig({})).toEqual(
+      expect.objectContaining({
+        linters: {}
+      })
+    )
+
+    expect(
+      getConfig({
+        '*.js': 'eslint'
+      })
+    ).toEqual(
+      expect.objectContaining({
+        linters: {
+          '*.js': 'eslint'
+        }
+      })
+    )
+
+    expect(
+      getConfig({
+        linters: {
+          '*.js': ['eslint --fix', 'git add'],
+          '.*rc': 'jsonlint'
+        }
+      })
+    ).toMatchSnapshot()
+  })
+
+  it('should deeply merge configs', () => {
+    expect(
+      getConfig({
+        globOptions: {
+          nocase: true
+        }
+      })
+    ).toEqual(
+      expect.objectContaining({
+        globOptions: {
+          nocase: true,
+          matchBase: true,
+          dot: true
+        }
+      })
+    )
+  })
+
+  it('should not add plain linters object to the full config', () => {
+    expect(
+      getConfig({
+        '*.js': 'eslint'
+      })
+    ).not.toEqual(
+      expect.objectContaining({
+        '*.js': 'eslint'
+      })
+    )
+  })
+
+  it('should not change config if the whole config was passed', () => {
+    const src = {
+      concurrent: false,
+      chunkSize: 2,
+      gitDir: '/to',
+      globOptions: {
+        matchBase: false,
+        dot: true
+      },
+      linters: {
+        '*.js': 'eslint'
+      },
+      subTaskConcurrency: 10,
+      renderer: 'custom',
+      verbose: true
+    }
+    expect(getConfig(cloneDeep(src))).toEqual(src)
+  })
+})
+
+describe('validateConfig', () => {
+  beforeEach(() => {
+    global.console = makeConsoleMock()
+  })
+
+  it('should throw and should print validation errors for invalid config', () => {
+    const invalidAdvancedConfig = {
+      foo: false,
+      chunkSize: 'string',
+      gitDir: 111
+    }
+    expect(() => validateConfig(getConfig(invalidAdvancedConfig))).toThrowErrorMatchingSnapshot()
+  })
+
+  it('should not throw and should print validation warnings for mixed config', () => {
+    const invalidMixedConfig = {
+      gitDir: './path/to/packagejson/',
+      '*.js': ['eslint --fix', 'git add']
+    }
+    expect(() => validateConfig(getConfig(invalidMixedConfig))).not.toThrow()
+    expect(console.printHistory()).toMatchSnapshot()
+  })
+
+  it('should not throw and should print nothing for simple valid config', () => {
+    const validSimpleConfig = {
+      '*.js': ['eslint --fix', 'git add']
+    }
+    expect(() => validateConfig(getConfig(validSimpleConfig))).not.toThrow()
+    expect(console.printHistory()).toMatchSnapshot()
+  })
+
+  it('should not throw and should print nothing for advanced valid config', () => {
+    const validAdvancedConfig = {
+      gitDir: '.',
+      linters: {
+        '*.js': ['eslint --fix', 'git add']
+      }
+    }
+    expect(() => validateConfig(getConfig(validAdvancedConfig))).not.toThrow()
+    expect(console.printHistory()).toMatchSnapshot()
+  })
+})
diff --git a/test/index.spec.js b/test/index.spec.js
new file mode 100644
index 0000000..291f580
--- /dev/null
+++ b/test/index.spec.js
@@ -0,0 +1,36 @@
+/* eslint no-unused-expressions: 0 */
+/* eslint no-console: 0 */
+/* eslint no-global-assign: 0 */
+
+import { makeConsoleMock } from 'consolemock'
+import cosmiconfig from 'cosmiconfig'
+import lintStaged from '../src/index'
+
+jest.mock('cosmiconfig')
+
+describe('lintStaged', () => {
+  beforeEach(() => {
+    console = makeConsoleMock()
+  })
+
+  it('should ouput config in verbose mode', async () => {
+    const config = {
+      verbose: true,
+      linters: {
+        '*': 'mytask'
+      }
+    }
+    cosmiconfig.mockImplementation(() => Promise.resolve({ config }))
+    await lintStaged()
+    expect(console.printHistory()).toMatchSnapshot()
+  })
+
+  it('should not output config in non verbose mode', async () => {
+    const config = {
+      '*': 'mytask'
+    }
+    cosmiconfig.mockImplementation(() => Promise.resolve({ config }))
+    await lintStaged()
+    expect(console.printHistory()).toMatchSnapshot()
+  })
+})
diff --git a/test/printErrors.spec.js b/test/printErrors.spec.js
new file mode 100644
index 0000000..40a59af
--- /dev/null
+++ b/test/printErrors.spec.js
@@ -0,0 +1,58 @@
+/* eslint no-unused-expressions: 0 */
+/* eslint no-console: 0 */
+/* eslint no-global-assign: 0 */
+
+import Listr from 'listr'
+import { makeConsoleMock } from 'consolemock'
+import printErrors from '../src/printErrors'
+
+describe('printErrors', () => {
+  beforeEach(() => {
+    console = makeConsoleMock()
+  })
+  it('should print plain errors', () => {
+    const err = new Error('We have a problem')
+    printErrors(err)
+    expect(console.printHistory()).toMatchSnapshot()
+  })
+
+  it('should print Listr nested errors', async () => {
+    const list = new Listr(
+      [
+        {
+          title: 'foo',
+          task: () => Promise.reject(new Error('Foo failed'))
+        },
+        {
+          title: 'bar',
+          task: () =>
+            new Listr([
+              {
+                title: 'unicorn',
+                task: () => Promise.reject(new Error('Unicorn failed'))
+              },
+              {
+                title: 'rainbow',
+                task: () => Promise.resolve()
+              }
+            ])
+        },
+        {
+          title: 'baz',
+          task: () => Promise.resolve()
+        }
+      ],
+      {
+        exitOnError: false,
+        renderer: 'silent'
+      }
+    )
+
+    try {
+      await list.run()
+    } catch (err) {
+      printErrors(err)
+      expect(console.printHistory()).toMatchSnapshot()
+    }
+  })
+})
diff --git a/test/readConfigOption.spec.js b/test/readConfigOption.spec.js
deleted file mode 100644
index afbf374..0000000
--- a/test/readConfigOption.spec.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import readConfigOption from '../src/readConfigOption'
-
-describe('readConfigOption', () => {
-  it('should return default value if config is undefined', () => {
-    const configOption = readConfigOption(undefined, 'my_key', 'default_value')
-    expect(configOption).toEqual('default_value')
-  })
-
-  it('should return default value if config option is undefined', () => {
-    const configOption = readConfigOption({}, 'my_key', 'default_value')
-    expect(configOption).toEqual('default_value')
-  })
-
-  it('should return config option if not undefined', () => {
-    const configOption = readConfigOption({ my_key: 'my_value' }, 'my_key', 'default_value')
-    expect(configOption).toEqual('my_value')
-  })
-})
diff --git a/test/resolveGitDir.spec.js b/test/resolveGitDir.spec.js
new file mode 100644
index 0000000..ed719ac
--- /dev/null
+++ b/test/resolveGitDir.spec.js
@@ -0,0 +1,24 @@
+import path from 'path'
+import resolveGitDir from '../src/resolveGitDir'
+
+describe('resolveGitDir', () => {
+  it('should resolve to current working dir if not set in config', () => {
+    const expected = path.resolve(process.cwd())
+    expect(resolveGitDir()).toEqual(expected)
+    expect(path.isAbsolute(resolveGitDir())).toBe(true)
+  })
+  it('should resolve to current working dir if set to default', () => {
+    const expected = path.resolve(process.cwd())
+    expect(resolveGitDir('.')).toEqual(expected)
+    expect(path.isAbsolute(resolveGitDir('.'))).toBe(true)
+  })
+  it('should resolve to relative dir from config', () => {
+    const expected = path.resolve(path.join(process.cwd(), '..'))
+    expect(resolveGitDir('..')).toEqual(expected)
+    expect(path.isAbsolute(resolveGitDir('..'))).toBe(true)
+  })
+  it('should resolve to absolute dir from config', () => {
+    expect(resolveGitDir('/path/to/some/dir')).toEqual('/path/to/some/dir')
+    expect(path.isAbsolute(resolveGitDir('/path/to/some/dir'))).toBe(true)
+  })
+})
diff --git a/test/runAll.spec.js b/test/runAll.spec.js
new file mode 100644
index 0000000..74be6d6
--- /dev/null
+++ b/test/runAll.spec.js
@@ -0,0 +1,26 @@
+/* eslint no-console: 0 */
+
+import { getConfig } from '../src/getConfig'
+import runAll from '../src/runAll'
+
+const packageJson = {
+  scripts: {
+    mytask: 'echo "Running task"'
+  }
+}
+
+describe('runAll', () => {
+  it('should throw when invalid config is provided', () => {
+    expect(() => runAll(packageJson, {})).toThrowErrorMatchingSnapshot()
+    expect(() => runAll(packageJson)).toThrowErrorMatchingSnapshot()
+  })
+
+  it('should return a promise', () => {
+    expect(runAll(packageJson, getConfig({}))).toBeInstanceOf(Promise)
+  })
+
+  it('should resolve the promise with no tasks', () => {
+    expect.assertions(1)
+    return expect(runAll(packageJson, getConfig({}))).resolves.toEqual('No tasks to run.')
+  })
+})
diff --git a/test/runScript-mock-findBin.spec.js b/test/runScript-mock-findBin.spec.js
index 72f9ed1..6088c03 100644
--- a/test/runScript-mock-findBin.spec.js
+++ b/test/runScript-mock-findBin.spec.js
@@ -2,8 +2,6 @@
 // `jest.mock` gets hoisted, but even with `jest.doMock` it wasn't working
 /* eslint no-underscore-dangle: 0 */
 
-import expect from 'expect'
-import isPromise from 'is-promise'
 import mockFn from 'execa'
 import runScript from '../src/runScript'
 
@@ -23,13 +21,6 @@ jest.mock(
   { virtual: true }
 )
 
-expect.extend({
-  toBeAPromise() {
-    expect.assert(isPromise(this.actual), 'expected %s to be a Promise', this.actual)
-    return this
-  }
-})
-
 const packageJSON = {
   scripts: {
     test: 'noop',
@@ -38,7 +29,7 @@ const packageJSON = {
   'lint-staged': {}
 }
 
-describe.only('runScript with absolute paths', () => {
+describe('runScript with absolute paths', () => {
   beforeEach(() => {
     mockFn.mockReset()
     mockFn.mockImplementation(() => Promise.resolve(true))
@@ -46,13 +37,10 @@ describe.only('runScript with absolute paths', () => {
 
   it('can pass `gitDir` as `cwd` to `execa()` when git is called via absolute path', async () => {
     const res = runScript(['git add'], ['test.js'], packageJSON, { gitDir: '../' })
-
     const taskPromise = res[0].task()
-    expect(taskPromise).toBeAPromise()
+    expect(taskPromise).toBeInstanceOf(Promise)
     await taskPromise
-    expect(mockFn.mock.calls.length).toEqual(1)
-    expect(mockFn.mock.calls[0][0]).toMatch('/usr/local/bin/git')
-    expect(mockFn.mock.calls[0][1]).toEqual(['add', 'test.js'])
-    expect(mockFn.mock.calls[0][2]).toEqual({ cwd: '../' })
+    expect(mockFn).toHaveBeenCalledTimes(1)
+    expect(mockFn).toHaveBeenCalledWith('/usr/local/bin/git', ['add', 'test.js'], { cwd: '../' })
   })
 })
diff --git a/test/runScript-mock-pMap.spec.js b/test/runScript-mock-pMap.spec.js
index 45e7b45..625436d 100644
--- a/test/runScript-mock-pMap.spec.js
+++ b/test/runScript-mock-pMap.spec.js
@@ -1,4 +1,3 @@
-import expect from 'expect'
 import pMap from 'p-map'
 import runScript from '../src/runScript'
 
@@ -15,13 +14,14 @@ const packageJSON = {
 describe('runScript', () => {
   it('should respect concurrency', () => {
     const res = runScript(['test'], ['test1.js', 'test2.js'], packageJSON, {
-      config: { chunkSize: 1, subTaskConcurrency: 1 }
+      chunkSize: 1,
+      subTaskConcurrency: 1
     })
     res[0].task()
     expect(pMap.mock.calls.length).toEqual(1)
     const pMapArgs = pMap.mock.calls[0]
     expect(pMapArgs[0]).toEqual([['test1.js'], ['test2.js']])
-    expect(pMapArgs[1]).toBeA('function')
+    expect(pMapArgs[1]).toBeInstanceOf(Function)
     expect(pMapArgs[2]).toEqual({ concurrency: 1 })
   })
 
diff --git a/test/runScript.spec.js b/test/runScript.spec.js
index 27706ed..69eadde 100644
--- a/test/runScript.spec.js
+++ b/test/runScript.spec.js
@@ -1,19 +1,10 @@
 /* eslint no-underscore-dangle: 0 */
 
-import expect from 'expect'
-import isPromise from 'is-promise'
 import mockFn from 'execa'
 import runScript from '../src/runScript'
 
 jest.mock('execa')
 
-expect.extend({
-  toBeAPromise() {
-    expect.assert(isPromise(this.actual), 'expected %s to be a Promise', this.actual)
-    return this
-  }
-})
-
 const packageJSON = {
   scripts: {
     test: 'noop',
@@ -29,7 +20,7 @@ describe('runScript', () => {
   })
 
   it('should return an array', () => {
-    expect(runScript('test', ['test.js'], packageJSON)).toBeA('array')
+    expect(runScript('test', ['test.js'], packageJSON)).toBeInstanceOf(Array)
   })
 
   it('should throw for non-existend script', () => {
@@ -42,9 +33,9 @@ describe('runScript', () => {
     const res = runScript('test', ['test.js'], packageJSON)
     expect(res.length).toBe(1)
     expect(res[0].title).toBe('test')
-    expect(res[0].task).toBeA('function')
+    expect(res[0].task).toBeInstanceOf(Function)
     const taskPromise = res[0].task()
-    expect(taskPromise).toBeAPromise()
+    expect(taskPromise).toBeInstanceOf(Promise)
     await taskPromise
   })
 
@@ -55,12 +46,12 @@ describe('runScript', () => {
     expect(res[1].title).toBe('test2')
 
     let taskPromise = res[0].task()
-    expect(taskPromise).toBeAPromise()
+    expect(taskPromise).toBeInstanceOf(Promise)
     await taskPromise
     expect(mockFn.mock.calls.length).toEqual(1)
     expect(mockFn.mock.calls[0]).toEqual(['npm', ['run', '--silent', 'test', '--', 'test.js'], {}])
     taskPromise = res[1].task()
-    expect(taskPromise).toBeAPromise()
+    expect(taskPromise).toBeInstanceOf(Promise)
     await taskPromise
     expect(mockFn.mock.calls.length).toEqual(2)
     expect(mockFn.mock.calls[1]).toEqual(['npm', ['run', '--silent', 'test2', '--', 'test.js'], {}])
@@ -68,10 +59,10 @@ describe('runScript', () => {
 
   it('should respect chunk size', async () => {
     const res = runScript(['test'], ['test1.js', 'test2.js'], packageJSON, {
-      config: { chunkSize: 1 }
+      chunkSize: 1
     })
     const taskPromise = res[0].task()
-    expect(taskPromise).toBeAPromise()
+    expect(taskPromise).toBeInstanceOf(Promise)
     await taskPromise
     expect(mockFn.mock.calls.length).toEqual(2)
     expect(mockFn.mock.calls[0]).toEqual(['npm', ['run', '--silent', 'test', '--', 'test1.js'], {}])
@@ -85,14 +76,14 @@ describe('runScript', () => {
     expect(res[1].title).toBe('git add')
 
     let taskPromise = res[0].task()
-    expect(taskPromise).toBeAPromise()
+    expect(taskPromise).toBeInstanceOf(Promise)
     await taskPromise
     expect(mockFn.mock.calls.length).toEqual(1)
     expect(mockFn.mock.calls[0][0]).toContain('node')
     expect(mockFn.mock.calls[0][1]).toEqual(['--arg=true', './myscript.js', 'test.js'])
 
     taskPromise = res[1].task()
-    expect(taskPromise).toBeAPromise()
+    expect(taskPromise).toBeInstanceOf(Promise)
     await taskPromise
     expect(mockFn.mock.calls.length).toEqual(2)
     expect(mockFn.mock.calls[1][0]).toContain('git')
@@ -102,13 +93,13 @@ describe('runScript', () => {
   it('should pass cwd to execa if gitDir option is set for non-npm tasks', async () => {
     const res = runScript(['test', 'git add'], ['test.js'], packageJSON, { gitDir: '../' })
     let taskPromise = res[0].task()
-    expect(taskPromise).toBeAPromise()
+    expect(taskPromise).toBeInstanceOf(Promise)
     await taskPromise
     expect(mockFn.mock.calls.length).toEqual(1)
     expect(mockFn.mock.calls[0]).toEqual(['npm', ['run', '--silent', 'test', '--', 'test.js'], {}])
 
     taskPromise = res[1].task()
-    expect(taskPromise).toBeAPromise()
+    expect(taskPromise).toBeInstanceOf(Promise)
     await taskPromise
     expect(mockFn.mock.calls.length).toEqual(2)
     expect(mockFn.mock.calls[1][0]).toMatch(/git$/)
@@ -119,7 +110,7 @@ describe('runScript', () => {
   it('should not pass `gitDir` as `cwd` to `execa()` if a non-git binary is called', async () => {
     const res = runScript(['jest'], ['test.js'], packageJSON, { gitDir: '../' })
     const taskPromise = res[0].task()
-    expect(taskPromise).toBeAPromise()
+    expect(taskPromise).toBeInstanceOf(Promise)
     await taskPromise
     expect(mockFn.mock.calls.length).toEqual(1)
     expect(mockFn.mock.calls[0]).toEqual(['jest', ['test.js'], {}])
@@ -128,7 +119,7 @@ describe('runScript', () => {
   it('should use --silent in non-verbose mode', async () => {
     const res = runScript('test', ['test.js'], packageJSON, { verbose: false })
     const taskPromise = res[0].task()
-    expect(taskPromise).toBeAPromise()
+    expect(taskPromise).toBeInstanceOf(Promise)
     await taskPromise
     expect(mockFn.mock.calls.length).toEqual(1)
     expect(mockFn.mock.calls[0]).toEqual(['npm', ['run', '--silent', 'test', '--', 'test.js'], {}])
@@ -137,7 +128,7 @@ describe('runScript', () => {
   it('should not use --silent in verbose mode', async () => {
     const res = runScript('test', ['test.js'], packageJSON, { verbose: true })
     const taskPromise = res[0].task()
-    expect(taskPromise).toBeAPromise()
+    expect(taskPromise).toBeInstanceOf(Promise)
     await taskPromise
     expect(mockFn.mock.calls.length).toEqual(1)
     expect(mockFn.mock.calls[0]).toEqual(['npm', ['run', 'test', '--', 'test.js'], {}])
diff --git a/testSetup.js b/testSetup.js
new file mode 100644
index 0000000..acb21d9
--- /dev/null
+++ b/testSetup.js
@@ -0,0 +1,2 @@
+// Overwrite TTY mode to always render without ansi codes
+process.stdout.isTTY = false