Repository: okonet/lint-staged
Pull request #1080 (2022-01-02 20:12:52)

feat: support multiple configuration files
This Merge Request succeeds @chengcyber's PR https://github.com/okonet/lint-staged/pull/1077 for introducing `--sparse-config` by making it the default behavior that _lint-staged_ supports multiple configuration files by discovering them for each staged file (or more specifically, the directory of the staged file).

For each staged file, the closest discovered config file will be used.

The [README](https://github.com/okonet/lint-staged/blob/20c0ee619bb838d1ad116c27c2bdb3a545319dd3/README.md#how-to-use-lint-staged-in-a-multi-package-monorepo) has been updated with:

> #### How to use lint-staged in a multi-package monorepo?
>
>Install _lint-staged_ on the monorepo root level, and add separate configuration files in each package. When running, _lint-staged_ will always use the configuration closest to a staged file, so having separate configuration files makes sure linters do not "leak" into other packages.
>For example, in a monorepo with `packages/frontend/.lintstagedrc.json` and `packages/backend/.lintstagedrc.json`, a staged file inside `packages/frontend/` will only match that configuration, and not the one in `packages/backend/`.
>
> **Note**: _lint-staged_ discovers the closest configuration to each staged file, even if that configuration doesn't include any matching globs. Given these example configurations:
> ```js
> // ./.lintstagedrc.json
> { "*.md": "prettier --write" }
> ```
> ```js
> // ./packages/frontend/.lintstagedrc.json
> { "*.js": "eslint --fix" }
> ```
 >When committing `./packages/frontend/README.md`, it **will not run** _prettier_, because the configuration in the `frontend/` directory is closer to the file and doesn't include it. You should treat all _lint-staged_ configuration files as isolated and separated from each other. You can always use JS files to "extend" configurations, for example:
> ```js
> import baseConfig from '../.lintstagedrc.js'
> export default {
>   ...baseConfig,
>   '*.js': 'eslint --fix',
> }
> ```

----

It also updates the "UI" of lint-staged a bit as seen in the updated [README](https://github.com/okonet/lint-staged/blob/20c0ee619bb838d1ad116c27c2bdb3a545319dd3/README.md):

```
$ git commit

✔ Preparing lint-staged...
❯ Running tasks for staged files...
  ❯ packages/frontend/.lintstagedrc.json — 1 file
    ↓ *.js — no files [SKIPPED]
    ❯ *.{json,md} — 1 file
      ⠹ prettier --write
  ↓ packages/backend/.lintstagedrc.json — 2 files
    ❯ *.js — 2 files
      ⠼ eslint --fix
    ↓ *.{json,md} — no files [SKIPPED]
◼ Applying modifications from tasks...
◼ Cleaning up temporary files...
```


codecov[bot]: # [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/1080?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) Report
> Merging [#1080](https://codecov.io/gh/okonet/lint-staged/pull/1080?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) (20c0ee6) into [master](https://codecov.io/gh/okonet/lint-staged/commit/c7ea3594c81f7c2724a7babc8e8d57926b4679c8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) (c7ea359) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/okonet/lint-staged/pull/1080/graphs/tree.svg?width=650&height=150&src=pr&token=LSoEgsjk4O&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov)](https://codecov.io/gh/okonet/lint-staged/pull/1080?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov)

```diff
@@            Coverage Diff            @@
##            master     #1080   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           21        22    +1     
  Lines          595       629   +34     
  Branches       159       166    +7     
=========================================
+ Hits           595       629   +34     
```


| [Impacted Files](https://codecov.io/gh/okonet/lint-staged/pull/1080?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) | Coverage Δ | |
|---|---|---|
| [lib/index.js](https://codecov.io/gh/okonet/lint-staged/pull/1080/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL2luZGV4Lmpz) | `100.00% <ø> (ø)` | |
| [lib/generateTasks.js](https://codecov.io/gh/okonet/lint-staged/pull/1080/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL2dlbmVyYXRlVGFza3MuanM=) | `100.00% <100.00%> (ø)` | |
| [lib/getConfigGroups.js](https://codecov.io/gh/okonet/lint-staged/pull/1080/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL2dldENvbmZpZ0dyb3Vwcy5qcw==) | `100.00% <100.00%> (ø)` | |
| [lib/getStagedFiles.js](https://codecov.io/gh/okonet/lint-staged/pull/1080/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL2dldFN0YWdlZEZpbGVzLmpz) | `100.00% <100.00%> (ø)` | |
| [lib/loadConfig.js](https://codecov.io/gh/okonet/lint-staged/pull/1080/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL2xvYWRDb25maWcuanM=) | `100.00% <100.00%> (ø)` | |
| [lib/runAll.js](https://codecov.io/gh/okonet/lint-staged/pull/1080/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL3J1bkFsbC5qcw==) | `100.00% <100.00%> (ø)` | |
| [lib/validateConfig.js](https://codecov.io/gh/okonet/lint-staged/pull/1080/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL3ZhbGlkYXRlQ29uZmlnLmpz) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/okonet/lint-staged/pull/1080?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/1080?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov). Last update [c7ea359...20c0ee6](https://codecov.io/gh/okonet/lint-staged/pull/1080?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov).

iiroj: Whew! Finally got the Windows tests to pass. What do you think @okonet?
iiroj: Hmm, I seem to have introduced some random failures into the tests.
iiroj: I think this is pretty stable now.
iiroj: What do you think, @okonet?
chengcyber: > multiple configs along with the --relative flag

Could you kindly elobrate more about the usage of `--relative` flag? Originally, the multiple config feature is behind `--sparse-config`, and i never used this flag before. so i guess i am not totally clear about how to work with `--relative`.
iiroj: @chengcyber by default tasks receive absolute file paths, for example `eslint --fix /Users/iiro/git/my-project/subdir/file.js`, but with `--relative`, they will be relative to `cwd`, so for example `eslint --fix subdir/file.js`.

Now, If I had the config at `subdir/`, it would mean `eslint --fix file.js`, and it runs inside `/Users/iiro/git/my-project/subdir/`.
chengcyber: @iiroj Thanks for your sharing about `--relative`!  I believe `--relative` maybe not much useful in multiple config cases. And looking forward to see this feature be officially released soon!
iiroj: Turns out we don't have to touch `--relative` because it's already running in the current `cwd`. Changing behaviour would make it a breaking change, so let's keep it as-is for now.
chengcyber: Is it possible to publish a prerelease version for this feature? My team is desperately need in it. 🤩
iiroj: Well, IMO we could just publish this. The improved error handling/logging pretty much just makes output prettier but doesn't otherwise change behavior. I can release another update for those. What do you think, @okonet ?
okonet: @iiroj that sounds good to me! Let's do a follow up then. 

Patch:
diff --git a/README.md b/README.md
index 813b3f0..e70f97e 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,30 @@
 
 Run linters against staged git files and don't let :poop: slip into your code base!
 
+```
+$ git commit
+
+✔ Preparing lint-staged...
+❯ Running tasks for staged files...
+  ❯ packages/frontend/.lintstagedrc.json — 1 file
+    ↓ *.js — no files [SKIPPED]
+    ❯ *.{json,md} — 1 file
+      ⠹ prettier --write
+  ↓ packages/backend/.lintstagedrc.json — 2 files
+    ❯ *.js — 2 files
+      ⠼ eslint --fix
+    ↓ *.{json,md} — no files [SKIPPED]
+◼ Applying modifications from tasks...
+◼ Cleaning up temporary files...
+```
+
+<details>
+<summary>See asciinema video</summary>
+
 [![asciicast](https://asciinema.org/a/199934.svg)](https://asciinema.org/a/199934)
 
+</details>
+
 ## Why
 
 Linting makes more sense when run before committing your code. By doing so you can ensure no errors go into the repository and enforce code style. But running a lint process on a whole project is slow, and linting results can be irrelevant. Ultimately you only want to lint files that will be committed.
@@ -116,6 +138,8 @@ Starting with v3.1 you can now use different ways of configuring lint-staged:
 
 Configuration should be an object where each value is a command to run and its key is a glob pattern to use for this command. This package uses [micromatch](https://github.com/micromatch/micromatch) for glob patterns. JavaScript files can also export advanced configuration as a function. See [Using JS configuration files](#using-js-configuration-files) for more info.
 
+You can also place multiple configuration files in different directories inside a project. For a given staged file, the closest configuration file will always be used. See ["How to use `lint-staged` in a multi-package monorepo?"](#how-to-use-lint-staged-in-a-multi-package-monorepo) for more info and an example.
+
 #### `package.json` example:
 
 ```json
@@ -644,12 +668,32 @@ _Thanks to [this comment](https://youtrack.jetbrains.com/issue/IDEA-135454#comme
 <details>
   <summary>Click to expand</summary>
 
-Starting with v5.0, `lint-staged` automatically resolves the git root **without any** additional configuration. You configure `lint-staged` as you normally would if your project root and git root were the same directory.
+Install _lint-staged_ on the monorepo root level, and add separate configuration files in each package. When running, _lint-staged_ will always use the configuration closest to a staged file, so having separate configuration files makes sure linters do not "leak" into other packages.
+
+For example, in a monorepo with `packages/frontend/.lintstagedrc.json` and `packages/backend/.lintstagedrc.json`, a staged file inside `packages/frontend/` will only match that configuration, and not the one in `packages/backend/`.
+
+**Note**: _lint-staged_ discovers the closest configuration to each staged file, even if that configuration doesn't include any matching globs. Given these example configurations:
 
-If you wish to use `lint-staged` in a multi package monorepo, it is recommended to install [`husky`](https://github.com/typicode/husky) in the root package.json.
-[`lerna`](https://github.com/lerna/lerna) can be used to execute the `precommit` script in all sub-packages.
+```js
+// ./.lintstagedrc.json
+{ "*.md": "prettier --write" }
+```
+
+```js
+// ./packages/frontend/.lintstagedrc.json
+{ "*.js": "eslint --fix" }
+```
+
+When committing `./packages/frontend/README.md`, it **will not run** _prettier_, because the configuration in the `frontend/` directory is closer to the file and doesn't include it. You should treat all _lint-staged_ configuration files as isolated and separated from each other. You can always use JS files to "extend" configurations, for example:
 
-Example repo: [sudo-suhas/lint-staged-multi-pkg](https://github.com/sudo-suhas/lint-staged-multi-pkg).
+```js
+import baseConfig from '../.lintstagedrc.js'
+
+export default {
+  ...baseConfig,
+  '*.js': 'eslint --fix',
+}
+```
 
 </details>
 
diff --git a/lib/dynamicImport.js b/lib/dynamicImport.js
new file mode 100644
index 0000000..75e228f
--- /dev/null
+++ b/lib/dynamicImport.js
@@ -0,0 +1,3 @@
+import { pathToFileURL } from 'url'
+
+export const dynamicImport = (path) => import(pathToFileURL(path)).then((module) => module.default)
diff --git a/lib/generateTasks.js b/lib/generateTasks.js
index d314137..164d69b 100644
--- a/lib/generateTasks.js
+++ b/lib/generateTasks.js
@@ -16,11 +16,10 @@ const debugLog = debug('lint-staged:generateTasks')
  * @param {boolean} [options.files] - Staged filepaths
  * @param {boolean} [options.relative] - Whether filepaths to should be relative to gitDir
  */
-export const generateTasks = ({ config, cwd = process.cwd(), gitDir, files, relative = false }) => {
+export const generateTasks = ({ config, cwd = process.cwd(), files, relative = false }) => {
   debugLog('Generating linter tasks')
 
-  const absoluteFiles = files.map((file) => normalize(path.resolve(gitDir, file)))
-  const relativeFiles = absoluteFiles.map((file) => normalize(path.relative(cwd, file)))
+  const relativeFiles = files.map((file) => normalize(path.relative(cwd, file)))
 
   return Object.entries(config).map(([rawPattern, commands]) => {
     let pattern = rawPattern
diff --git a/lib/getConfigGroups.js b/lib/getConfigGroups.js
new file mode 100644
index 0000000..be0da76
--- /dev/null
+++ b/lib/getConfigGroups.js
@@ -0,0 +1,77 @@
+/** @typedef {import('./index').Logger} Logger */
+
+import path from 'path'
+
+import { loadConfig } from './loadConfig.js'
+import { ConfigNotFoundError } from './symbols.js'
+import { validateConfig } from './validateConfig.js'
+
+/**
+ * Return matched files grouped by their configuration.
+ *
+ * @param {object} options
+ * @param {Object} [options.configObject] - Explicit config object from the js API
+ * @param {string} [options.configPath] - Explicit path to a config file
+ * @param {string} [options.cwd] - Current working directory
+ * @param {Logger} logger
+ */
+export const getConfigGroups = async ({ configObject, configPath, files }, logger = console) => {
+  // Return explicit config object from js API
+  if (configObject) {
+    const config = validateConfig(configObject, 'config object', logger)
+    return { '': { config, files } }
+  }
+
+  // Use only explicit config path instead of discovering multiple
+  if (configPath) {
+    const { config, filepath } = await loadConfig({ configPath }, logger)
+
+    if (!config) {
+      logger.error(`${ConfigNotFoundError.message}.`)
+      throw ConfigNotFoundError
+    }
+
+    const validatedConfig = validateConfig(config, filepath, logger)
+    return { [configPath]: { config: validatedConfig, files } }
+  }
+
+  // Group files by their base directory
+  const filesByDir = files.reduce((acc, file) => {
+    const dir = path.normalize(path.dirname(file))
+
+    if (dir in acc) {
+      acc[dir].push(file)
+    } else {
+      acc[dir] = [file]
+    }
+
+    return acc
+  }, {})
+
+  // Group files by their discovered config
+  // { '.lintstagedrc.json': { config: {...}, files: [...] } }
+  const configGroups = {}
+
+  await Promise.all(
+    Object.entries(filesByDir).map(([dir, files]) => {
+      // Discover config from the base directory of the file
+      return loadConfig({ cwd: dir }, logger).then(({ config, filepath }) => {
+        if (!config) {
+          logger.error(`${ConfigNotFoundError.message}.`)
+          throw ConfigNotFoundError
+        }
+
+        if (filepath in configGroups) {
+          // Re-use cached config and skip validation
+          configGroups[filepath].files.push(...files)
+          return
+        }
+
+        const validatedConfig = validateConfig(config, filepath, logger)
+        configGroups[filepath] = { config: validatedConfig, files }
+      })
+    })
+  )
+
+  return configGroups
+}
diff --git a/lib/getStagedFiles.js b/lib/getStagedFiles.js
index 69d79aa..a6a5573 100644
--- a/lib/getStagedFiles.js
+++ b/lib/getStagedFiles.js
@@ -1,16 +1,28 @@
+import path from 'path'
+
+import normalize from 'normalize-path'
+
 import { execGit } from './execGit.js'
 
-export const getStagedFiles = async (options) => {
+export const getStagedFiles = async ({ cwd = process.cwd() } = {}) => {
   try {
     // Docs for --diff-filter option: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---diff-filterACDMRTUXB82308203
     // Docs for -z option: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt--z
-    const lines = await execGit(
-      ['diff', '--staged', '--diff-filter=ACMR', '--name-only', '-z'],
-      options
+    const lines = await execGit(['diff', '--staged', '--diff-filter=ACMR', '--name-only', '-z'], {
+      cwd,
+    })
+
+    if (!lines) return []
+
+    // With `-z`, git prints `fileA\u0000fileB\u0000fileC\u0000` so we need to
+    // remove the last occurrence of `\u0000` before splitting
+    return (
+      lines
+        // eslint-disable-next-line no-control-regex
+        .replace(/\u0000$/, '')
+        .split('\u0000')
+        .map((file) => normalize(path.resolve(cwd, file)))
     )
-    // With `-z`, git prints `fileA\u0000fileB\u0000fileC\u0000` so we need to remove the last occurrence of `\u0000` before splitting
-    // eslint-disable-next-line no-control-regex
-    return lines ? lines.replace(/\u0000$/, '').split('\u0000') : []
   } catch {
     return null
   }
diff --git a/lib/index.js b/lib/index.js
index 4048b44..e01d6d9 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,17 +1,9 @@
 import debug from 'debug'
-import inspect from 'object-inspect'
 
-import { loadConfig } from './loadConfig.js'
 import { PREVENTED_EMPTY_COMMIT, GIT_ERROR, RESTORE_STASH_EXAMPLE } from './messages.js'
 import { printTaskOutput } from './printTaskOutput.js'
 import { runAll } from './runAll.js'
-import {
-  ApplyEmptyCommitError,
-  ConfigNotFoundError,
-  GetBackupStashError,
-  GitError,
-} from './symbols.js'
-import { validateConfig } from './validateConfig.js'
+import { ApplyEmptyCommitError, GetBackupStashError, GitError } from './symbols.js'
 import { validateOptions } from './validateOptions.js'
 
 const debugLog = debug('lint-staged')
@@ -58,25 +50,6 @@ const lintStaged = async (
 ) => {
   await validateOptions({ shell }, logger)
 
-  const inputConfig = configObject || (await loadConfig({ configPath, cwd }, logger))
-
-  if (!inputConfig) {
-    logger.error(`${ConfigNotFoundError.message}.`)
-    throw ConfigNotFoundError
-  }
-
-  const config = validateConfig(inputConfig, logger)
-
-  if (debug) {
-    // Log using logger to be able to test through `consolemock`.
-    logger.log('Running lint-staged with the following config:')
-    logger.log(inspect(config, { indent: 2 }))
-  } else {
-    // We might not be in debug mode but `DEBUG=lint-staged*` could have
-    // been set.
-    debugLog('lint-staged config:\n%O', config)
-  }
-
   // Unset GIT_LITERAL_PATHSPECS to not mess with path interpretation
   debugLog('Unset GIT_LITERAL_PATHSPECS (was `%s`)', process.env.GIT_LITERAL_PATHSPECS)
   delete process.env.GIT_LITERAL_PATHSPECS
@@ -86,7 +59,8 @@ const lintStaged = async (
       {
         allowEmpty,
         concurrent,
-        config,
+        configObject,
+        configPath,
         cwd,
         debug,
         maxArgLength,
diff --git a/lib/loadConfig.js b/lib/loadConfig.js
index 8c9b8c5..78fa3c7 100644
--- a/lib/loadConfig.js
+++ b/lib/loadConfig.js
@@ -1,11 +1,10 @@
 /** @typedef {import('./index').Logger} Logger */
 
-import { pathToFileURL } from 'url'
-
 import debug from 'debug'
 import { lilconfig } from 'lilconfig'
 import YAML from 'yaml'
 
+import { dynamicImport } from './dynamicImport.js'
 import { resolveConfig } from './resolveConfig.js'
 
 const debugLog = debug('lint-staged:loadConfig')
@@ -28,9 +27,6 @@ const searchPlaces = [
   'lint-staged.config.cjs',
 ]
 
-/** exported for tests */
-export const dynamicImport = (path) => import(pathToFileURL(path)).then((module) => module.default)
-
 const jsonParse = (path, content) => JSON.parse(content)
 
 const yamlParse = (path, content) => YAML.parse(content)
@@ -51,6 +47,8 @@ const loaders = {
   noExt: yamlParse,
 }
 
+const explorer = lilconfig('lint-staged', { searchPlaces, loaders })
+
 /**
  * @param {object} options
  * @param {string} [options.configPath] - Explicit path to a config file
@@ -64,22 +62,22 @@ export const loadConfig = async ({ configPath, cwd }, logger) => {
       debugLog('Searching for configuration from `%s`...', cwd)
     }
 
-    const explorer = lilconfig('lint-staged', { searchPlaces, loaders })
-
     const result = await (configPath
       ? explorer.load(resolveConfig(configPath))
       : explorer.search(cwd))
-    if (!result) return null
+
+    if (!result) return {}
 
     // config is a promise when using the `dynamicImport` loader
     const config = await result.config
+    const filepath = result.filepath
 
-    debugLog('Successfully loaded config from `%s`:\n%O', result.filepath, config)
+    debugLog('Successfully loaded config from `%s`:\n%O', filepath, config)
 
-    return config
+    return { config, filepath }
   } catch (error) {
     debugLog('Failed to load configuration!')
     logger.error(error)
-    return null
+    return {}
   }
 }
diff --git a/lib/runAll.js b/lib/runAll.js
index 1ba7260..dfe7997 100644
--- a/lib/runAll.js
+++ b/lib/runAll.js
@@ -1,11 +1,16 @@
 /** @typedef {import('./index').Logger} Logger */
 
+import path from 'path'
+
+import { dim } from 'colorette'
 import debug from 'debug'
 import { Listr } from 'listr2'
+import normalize from 'normalize-path'
 
 import { chunkFiles } from './chunkFiles.js'
 import { execGit } from './execGit.js'
 import { generateTasks } from './generateTasks.js'
+import { getConfigGroups } from './getConfigGroups.js'
 import { getRenderer } from './getRenderer.js'
 import { getStagedFiles } from './getStagedFiles.js'
 import { GitWorkflow } from './gitWorkflow.js'
@@ -40,10 +45,11 @@ const createError = (ctx) => Object.assign(new Error('lint-staged failed'), { ct
  * Executes all tasks and either resolves or rejects the promise
  *
  * @param {object} options
- * @param {Object} [options.allowEmpty] - Allow empty commits when tasks revert all staged changes
+ * @param {boolean} [options.allowEmpty] - Allow empty commits when tasks revert all staged changes
  * @param {boolean | number} [options.concurrent] - The number of tasks to run concurrently, or false to run tasks serially
- * @param {Object} [options.config] - Task configuration
- * @param {Object} [options.cwd] - Current working directory
+ * @param {Object} [options.configObject] - Explicit config object from the js API
+ * @param {string} [options.configPath] - Explicit path to a config file
+ * @param {string} [options.cwd] - Current working directory
  * @param {boolean} [options.debug] - Enable debug mode
  * @param {number} [options.maxArgLength] - Maximum argument string length
  * @param {boolean} [options.quiet] - Disable lint-staged’s own console output
@@ -58,7 +64,8 @@ export const runAll = async (
   {
     allowEmpty = false,
     concurrent = true,
-    config,
+    configObject,
+    configPath,
     cwd = process.cwd(),
     debug = false,
     maxArgLength,
@@ -107,9 +114,7 @@ export const runAll = async (
     return ctx
   }
 
-  const stagedFileChunks = chunkFiles({ baseDir: gitDir, files, maxArgLength, relative })
-  const chunkCount = stagedFileChunks.length
-  if (chunkCount > 1) debugLog(`Chunked staged files into ${chunkCount} part`, chunkCount)
+  const configGroups = await getConfigGroups({ configObject, configPath, files }, logger)
 
   // lint-staged 10 will automatically add modifications to index
   // Warn user when their command includes `git add`
@@ -128,62 +133,76 @@ export const runAll = async (
   // Set of all staged files that matched a task glob. Values in a set are unique.
   const matchedFiles = new Set()
 
-  for (const [index, files] of stagedFileChunks.entries()) {
-    const chunkTasks = generateTasks({ config, cwd, gitDir, files, relative })
-    const chunkListrTasks = []
-
-    for (const task of chunkTasks) {
-      const subTasks = await makeCmdTasks({
-        commands: task.commands,
-        cwd,
-        files: task.fileList,
-        gitDir,
-        renderer: listrOptions.renderer,
-        shell,
-        verbose,
-      })
+  for (const [configPath, { config, files }] of Object.entries(configGroups)) {
+    const stagedFileChunks = chunkFiles({ baseDir: gitDir, files, maxArgLength, relative })
 
-      // Add files from task to match set
-      task.fileList.forEach((file) => {
-        matchedFiles.add(file)
-      })
+    const chunkCount = stagedFileChunks.length
+    if (chunkCount > 1) {
+      debugLog('Chunked staged files from `%s` into %d part', configPath, chunkCount)
+    }
+
+    for (const [index, files] of stagedFileChunks.entries()) {
+      const relativeConfig = normalize(path.relative(cwd, configPath))
+
+      const chunkListrTasks = await Promise.all(
+        generateTasks({ config, cwd, files, relative }).map((task) =>
+          makeCmdTasks({
+            commands: task.commands,
+            cwd,
+            files: task.fileList,
+            gitDir,
+            renderer: listrOptions.renderer,
+            shell,
+            verbose,
+          }).then((subTasks) => {
+            // Add files from task to match set
+            task.fileList.forEach((file) => {
+              matchedFiles.add(file)
+            })
+
+            hasDeprecatedGitAdd =
+              hasDeprecatedGitAdd || subTasks.some((subTask) => subTask.command === 'git add')
 
-      hasDeprecatedGitAdd =
-        hasDeprecatedGitAdd || subTasks.some((subTask) => subTask.command === 'git add')
-
-      chunkListrTasks.push({
-        title: `Running tasks for ${task.pattern}`,
-        task: async () =>
-          new Listr(subTasks, {
-            // In sub-tasks we don't want to run concurrently
-            // and we want to abort on errors
-            ...listrOptions,
-            concurrent: false,
-            exitOnError: true,
-          }),
+            const fileCount = task.fileList.length
+
+            return {
+              title: `${task.pattern}${dim(` — ${fileCount} ${fileCount > 1 ? 'files' : 'file'}`)}`,
+              task: async () =>
+                new Listr(subTasks, {
+                  // In sub-tasks we don't want to run concurrently
+                  // and we want to abort on errors
+                  ...listrOptions,
+                  concurrent: false,
+                  exitOnError: true,
+                }),
+              skip: () => {
+                // Skip task when no files matched
+                if (fileCount === 0) {
+                  return `${task.pattern}${dim(' — no files')}`
+                }
+                return false
+              },
+            }
+          })
+        )
+      )
+
+      listrTasks.push({
+        title:
+          `${relativeConfig}${dim(` — ${files.length} ${files.length > 1 ? 'files' : 'file'}`)}` +
+          (chunkCount > 1 ? dim(` (chunk ${index + 1}/${chunkCount})...`) : ''),
+        task: () => new Listr(chunkListrTasks, { ...listrOptions, concurrent, exitOnError: true }),
         skip: () => {
-          // Skip task when no files matched
-          if (task.fileList.length === 0) {
-            return `No staged files match ${task.pattern}`
+          // Skip if the first step (backup) failed
+          if (ctx.errors.has(GitError)) return SKIPPED_GIT_ERROR
+          // Skip chunk when no every task is skipped (due to no matches)
+          if (chunkListrTasks.every((task) => task.skip())) {
+            return `${relativeConfig}${dim(' — no tasks to run')}`
           }
           return false
         },
       })
     }
-
-    listrTasks.push({
-      // No need to show number of task chunks when there's only one
-      title:
-        chunkCount > 1 ? `Running tasks (chunk ${index + 1}/${chunkCount})...` : 'Running tasks...',
-      task: () => new Listr(chunkListrTasks, { ...listrOptions, concurrent }),
-      skip: () => {
-        // Skip if the first step (backup) failed
-        if (ctx.errors.has(GitError)) return SKIPPED_GIT_ERROR
-        // Skip chunk when no every task is skipped (due to no matches)
-        if (chunkListrTasks.every((task) => task.skip())) return 'No tasks to run.'
-        return false
-      },
-    })
   }
 
   if (hasDeprecatedGitAdd) {
@@ -211,7 +230,7 @@ export const runAll = async (
   const runner = new Listr(
     [
       {
-        title: 'Preparing...',
+        title: 'Preparing lint-staged...',
         task: (ctx) => git.prepare(ctx),
       },
       {
@@ -219,9 +238,13 @@ export const runAll = async (
         task: (ctx) => git.hideUnstagedChanges(ctx),
         enabled: hasPartiallyStagedFiles,
       },
-      ...listrTasks,
       {
-        title: 'Applying modifications...',
+        title: `Running tasks for staged files...`,
+        task: () => new Listr(listrTasks, { ...listrOptions, concurrent }),
+        skip: () => listrTasks.every((task) => task.skip()),
+      },
+      {
+        title: 'Applying modifications from tasks...',
         task: (ctx) => git.applyModifications(ctx),
         skip: applyModificationsSkipped,
       },
@@ -238,7 +261,7 @@ export const runAll = async (
         skip: restoreOriginalStateSkipped,
       },
       {
-        title: 'Cleaning up...',
+        title: 'Cleaning up temporary files...',
         task: (ctx) => git.cleanup(ctx),
         enabled: cleanupEnabled,
         skip: cleanupSkipped,
diff --git a/lib/validateConfig.js b/lib/validateConfig.js
index 11e1cee..d093824 100644
--- a/lib/validateConfig.js
+++ b/lib/validateConfig.js
@@ -1,4 +1,7 @@
+/** @typedef {import('./index').Logger} Logger */
+
 import debug from 'debug'
+import inspect from 'object-inspect'
 
 import { configurationError } from './messages.js'
 import { ConfigEmptyError, ConfigFormatError } from './symbols.js'
@@ -21,11 +24,13 @@ const TEST_DEPRECATED_KEYS = new Map([
 
 /**
  * Runs config validation. Throws error if the config is not valid.
- * @param config {Object}
- * @returns config {Object}
+ * @param {Object} config
+ * @param {string} configPath
+ * @param {Logger} logger
+ * @returns {Object} config
  */
-export const validateConfig = (config, logger) => {
-  debugLog('Validating config')
+export const validateConfig = (config, configPath, logger) => {
+  debugLog('Validating config from `%s`...', configPath)
 
   if (!config || (typeof config !== 'object' && typeof config !== 'function')) {
     throw ConfigFormatError
@@ -103,5 +108,8 @@ See https://github.com/okonet/lint-staged#configuration.`)
     throw new Error(message)
   }
 
+  debugLog('Validated config from `%s`:', configPath)
+  debugLog(inspect(config, { indent: 2 }))
+
   return validatedConfig
 }


Test patch:
diff --git a/test/__snapshots__/validateConfig.spec.js.snap b/test/__snapshots__/validateConfig.spec.js.snap
index 38bf1af..734a303 100644
--- a/test/__snapshots__/validateConfig.spec.js.snap
+++ b/test/__snapshots__/validateConfig.spec.js.snap
@@ -10,6 +10,8 @@ exports[`validateConfig should throw and should print validation errors for inva
 
 exports[`validateConfig should throw and should print validation errors for invalid config 1 1`] = `"Configuration should be an object or a function"`;
 
+exports[`validateConfig should throw for empty config 1`] = `"Configuration should not be empty"`;
+
 exports[`validateConfig should throw when detecting deprecated advanced configuration 1`] = `
 "✖ Validation Error:
 
diff --git a/test/dynamicImport.spec.js b/test/dynamicImport.spec.js
new file mode 100644
index 0000000..9abc014
--- /dev/null
+++ b/test/dynamicImport.spec.js
@@ -0,0 +1,7 @@
+import { dynamicImport } from '../lib/dynamicImport'
+
+describe('dynamicImport', () => {
+  it('should log errors into console', () => {
+    expect(() => dynamicImport('not-found.js')).rejects.toThrowError(`Cannot find module`)
+  })
+})
diff --git a/test/generateTasks.spec.js b/test/generateTasks.spec.js
index c8208e0..227c478 100644
--- a/test/generateTasks.spec.js
+++ b/test/generateTasks.spec.js
@@ -1,39 +1,34 @@
-import os from 'os'
 import path from 'path'
 
 import normalize from 'normalize-path'
 
 import { generateTasks } from '../lib/generateTasks'
-import { resolveGitRepo } from '../lib/resolveGitRepo'
 
-const normalizePath = (path) => normalize(path)
+// Windows filepaths
+const normalizePath = (input) => normalize(path.resolve('/', input))
+
+const cwd = '/repo'
 
 const files = [
-  'test.js',
-  'deeper/test.js',
-  'deeper/test2.js',
-  'even/deeper/test.js',
-  '.hidden/test.js',
-
-  'test.css',
-  'deeper/test1.css',
-  'deeper/test2.css',
-  'even/deeper/test.css',
-  '.hidden/test.css',
-
-  'test.txt',
-  'deeper/test.txt',
-  'deeper/test2.txt',
-  'even/deeper/test.txt',
-  '.hidden/test.txt',
+  '/repo/test.js',
+  '/repo/deeper/test.js',
+  '/repo/deeper/test2.js',
+  '/repo/even/deeper/test.js',
+  '/repo/.hidden/test.js',
+
+  '/repo/test.css',
+  '/repo/deeper/test1.css',
+  '/repo/deeper/test2.css',
+  '/repo/even/deeper/test.css',
+  '/repo/.hidden/test.css',
+
+  '/repo/test.txt',
+  '/repo/deeper/test.txt',
+  '/repo/deeper/test2.txt',
+  '/repo/even/deeper/test.txt',
+  '/repo/.hidden/test.txt',
 ]
 
-// Mocks get hoisted
-jest.mock('../lib/resolveGitRepo.js')
-const gitDir = path.join(os.tmpdir(), 'tmp-lint-staged')
-resolveGitRepo.mockResolvedValue({ gitDir })
-const cwd = gitDir
-
 const config = {
   '*.js': 'root-js',
   '**/*.js': 'any-js',
@@ -50,17 +45,16 @@ describe('generateTasks', () => {
       config: {
         '*': 'lint',
       },
-      gitDir,
       files,
     })
+
     task.fileList.forEach((file) => {
       expect(path.isAbsolute(file)).toBe(true)
     })
   })
 
   it('should not match non-children files', async () => {
-    const relPath = path.join(process.cwd(), '..')
-    const result = await generateTasks({ config, cwd, gitDir: relPath, files })
+    const result = await generateTasks({ config, cwd: '/test', files })
     const linter = result.find((item) => item.pattern === '*.js')
     expect(linter).toEqual({
       pattern: '*.js',
@@ -70,7 +64,7 @@ describe('generateTasks', () => {
   })
 
   it('should return an empty file list for linters with no matches.', async () => {
-    const result = await generateTasks({ config, cwd, gitDir, files })
+    const result = await generateTasks({ config, cwd, files })
 
     result.forEach((task) => {
       if (task.commands === 'unknown-js' || task.commands === 'parent-dir-css-or-js') {
@@ -82,74 +76,74 @@ describe('generateTasks', () => {
   })
 
   it('should match pattern "*.js"', async () => {
-    const result = await generateTasks({ config, cwd, gitDir, files })
+    const result = await generateTasks({ config, cwd, files })
     const linter = result.find((item) => item.pattern === '*.js')
     expect(linter).toEqual({
       pattern: '*.js',
       commands: 'root-js',
       fileList: [
-        `${gitDir}/test.js`,
-        `${gitDir}/deeper/test.js`,
-        `${gitDir}/deeper/test2.js`,
-        `${gitDir}/even/deeper/test.js`,
-        `${gitDir}/.hidden/test.js`,
+        `/repo/test.js`,
+        `/repo/deeper/test.js`,
+        `/repo/deeper/test2.js`,
+        `/repo/even/deeper/test.js`,
+        `/repo/.hidden/test.js`,
       ].map(normalizePath),
     })
   })
 
   it('should match pattern "**/*.js"', async () => {
-    const result = await generateTasks({ config, cwd, gitDir, files })
+    const result = await generateTasks({ config, cwd, files })
     const linter = result.find((item) => item.pattern === '**/*.js')
     expect(linter).toEqual({
       pattern: '**/*.js',
       commands: 'any-js',
       fileList: [
-        `${gitDir}/test.js`,
-        `${gitDir}/deeper/test.js`,
-        `${gitDir}/deeper/test2.js`,
-        `${gitDir}/even/deeper/test.js`,
-        `${gitDir}/.hidden/test.js`,
+        `/repo/test.js`,
+        `/repo/deeper/test.js`,
+        `/repo/deeper/test2.js`,
+        `/repo/even/deeper/test.js`,
+        `/repo/.hidden/test.js`,
       ].map(normalizePath),
     })
   })
 
   it('should match pattern "deeper/*.js"', async () => {
-    const result = await generateTasks({ config, cwd, gitDir, files })
+    const result = await generateTasks({ config, cwd, files })
     const linter = result.find((item) => item.pattern === 'deeper/*.js')
     expect(linter).toEqual({
       pattern: 'deeper/*.js',
       commands: 'deeper-js',
-      fileList: [`${gitDir}/deeper/test.js`, `${gitDir}/deeper/test2.js`].map(normalizePath),
+      fileList: [`/repo/deeper/test.js`, `/repo/deeper/test2.js`].map(normalizePath),
     })
   })
 
   it('should match pattern ".hidden/*.js"', async () => {
-    const result = await generateTasks({ config, cwd, gitDir, files })
+    const result = await generateTasks({ config, cwd, files })
     const linter = result.find((item) => item.pattern === '.hidden/*.js')
     expect(linter).toEqual({
       pattern: '.hidden/*.js',
       commands: 'hidden-js',
-      fileList: [`${gitDir}/.hidden/test.js`].map(normalizePath),
+      fileList: [`/repo/.hidden/test.js`].map(normalizePath),
     })
   })
 
   it('should match pattern "*.{css,js}"', async () => {
-    const result = await generateTasks({ config, cwd, gitDir, files })
+    const result = await generateTasks({ config, cwd, files })
     const linter = result.find((item) => item.pattern === '*.{css,js}')
     expect(linter).toEqual({
       pattern: '*.{css,js}',
       commands: 'root-css-or-js',
       fileList: [
-        `${gitDir}/test.js`,
-        `${gitDir}/deeper/test.js`,
-        `${gitDir}/deeper/test2.js`,
-        `${gitDir}/even/deeper/test.js`,
-        `${gitDir}/.hidden/test.js`,
-        `${gitDir}/test.css`,
-        `${gitDir}/deeper/test1.css`,
-        `${gitDir}/deeper/test2.css`,
-        `${gitDir}/even/deeper/test.css`,
-        `${gitDir}/.hidden/test.css`,
+        `/repo/test.js`,
+        `/repo/deeper/test.js`,
+        `/repo/deeper/test2.js`,
+        `/repo/even/deeper/test.js`,
+        `/repo/.hidden/test.js`,
+        `/repo/test.css`,
+        `/repo/deeper/test1.css`,
+        `/repo/deeper/test2.css`,
+        `/repo/even/deeper/test.css`,
+        `/repo/.hidden/test.css`,
       ].map(normalizePath),
     })
   })
@@ -160,7 +154,7 @@ describe('generateTasks', () => {
         'test{1..2}.css': 'lint',
       },
       cwd,
-      gitDir,
+
       files,
     })
 
@@ -169,42 +163,40 @@ describe('generateTasks', () => {
     expect(linter).toEqual({
       pattern: 'test{1..2}.css',
       commands: 'lint',
-      fileList: [`${gitDir}/deeper/test1.css`, `${gitDir}/deeper/test2.css`].map(normalizePath),
+      fileList: [`/repo/deeper/test1.css`, `/repo/deeper/test2.css`].map(normalizePath),
     })
   })
 
   it('should not match files in parent directory by default', async () => {
     const result = await generateTasks({
       config,
-      cwd: path.join(gitDir, 'deeper'),
-      gitDir,
+      cwd: '/repo/deeper',
       files,
     })
     const linter = result.find((item) => item.pattern === '*.js')
     expect(linter).toEqual({
       pattern: '*.js',
       commands: 'root-js',
-      fileList: [`${gitDir}/deeper/test.js`, `${gitDir}/deeper/test2.js`].map(normalizePath),
+      fileList: [`/repo/deeper/test.js`, `/repo/deeper/test2.js`].map(normalizePath),
     })
   })
 
   it('should match files in parent directory when pattern starts with "../"', async () => {
     const result = await generateTasks({
       config,
-      cwd: path.join(gitDir, 'deeper'),
-      gitDir,
+      cwd: '/repo/deeper',
       files,
     })
     const linter = result.find((item) => item.pattern === '../*.{css,js}')
     expect(linter).toEqual({
       commands: 'parent-dir-css-or-js',
-      fileList: [`${gitDir}/test.js`, `${gitDir}/test.css`].map(normalizePath),
+      fileList: [`/repo/test.js`, `/repo/test.css`].map(normalizePath),
       pattern: '../*.{css,js}',
     })
   })
 
   it('should be able to return relative paths for "*.{css,js}"', async () => {
-    const result = await generateTasks({ config, cwd, gitDir, files, relative: true })
+    const result = await generateTasks({ config, cwd, files, relative: true })
     const linter = result.find((item) => item.pattern === '*.{css,js}')
     expect(linter).toEqual({
       pattern: '*.{css,js}',
@@ -220,7 +212,7 @@ describe('generateTasks', () => {
         'deeper/test2.css',
         'even/deeper/test.css',
         '.hidden/test.css',
-      ].map(normalizePath),
+      ],
     })
   })
 })
diff --git a/test/getConfigGroups.spec.js b/test/getConfigGroups.spec.js
new file mode 100644
index 0000000..29af85a
--- /dev/null
+++ b/test/getConfigGroups.spec.js
@@ -0,0 +1,56 @@
+import makeConsoleMock from 'consolemock'
+
+import { getConfigGroups } from '../lib/getConfigGroups'
+import { loadConfig } from '../lib/loadConfig'
+
+jest.mock('../lib/loadConfig', () => ({
+  // config not found
+  loadConfig: jest.fn(async () => ({})),
+}))
+
+const globalConsoleTemp = console
+
+const config = {
+  '*.js': 'my-task',
+}
+
+describe('getConfigGroups', () => {
+  beforeEach(() => {
+    console = makeConsoleMock()
+  })
+
+  afterEach(() => {
+    console.printHistory()
+    console = globalConsoleTemp
+  })
+
+  it('should throw when config path not found', async () => {
+    await expect(getConfigGroups({ configPath: '/' })).rejects.toThrowErrorMatchingInlineSnapshot(
+      `"Configuration could not be found"`
+    )
+  })
+
+  it('should throw when config not found', async () => {
+    await expect(
+      getConfigGroups({ files: ['/foo.js'] })
+    ).rejects.toThrowErrorMatchingInlineSnapshot(`"Configuration could not be found"`)
+  })
+
+  it('should find config files for all staged files', async () => {
+    // '/foo.js' and '/bar.js'
+    loadConfig.mockResolvedValueOnce({ config, filepath: '/.lintstagedrc.json' })
+    // '/deeper/foo.js'
+    loadConfig.mockResolvedValueOnce({ config, filepath: '/deeper/.lintstagedrc.json' })
+    // '/even/deeper/foo.js'
+    loadConfig.mockResolvedValueOnce({ config, filepath: '/deeper/.lintstagedrc.json' })
+
+    const configGroups = await getConfigGroups({
+      files: ['/foo.js', '/bar.js', '/deeper/foo.js', '/even/deeper/foo.js'],
+    })
+
+    expect(configGroups).toEqual({
+      '/.lintstagedrc.json': { config, files: ['/foo.js', '/bar.js'] },
+      '/deeper/.lintstagedrc.json': { config, files: ['/deeper/foo.js', '/even/deeper/foo.js'] },
+    })
+  })
+})
diff --git a/test/getStagedFiles.spec.js b/test/getStagedFiles.spec.js
index 20f6b98..1586646 100644
--- a/test/getStagedFiles.spec.js
+++ b/test/getStagedFiles.spec.js
@@ -1,13 +1,21 @@
+import path from 'path'
+
+import normalize from 'normalize-path'
+
 import { getStagedFiles } from '../lib/getStagedFiles'
 import { execGit } from '../lib/execGit'
 
 jest.mock('../lib/execGit')
 
+// Windows filepaths
+const normalizePath = (input) => normalize(path.resolve('/', input))
+
 describe('getStagedFiles', () => {
   it('should return array of file names', async () => {
     execGit.mockImplementationOnce(async () => 'foo.js\u0000bar.js\u0000')
-    const staged = await getStagedFiles()
-    expect(staged).toEqual(['foo.js', 'bar.js'])
+    const staged = await getStagedFiles({ cwd: '/' })
+    // Windows filepaths
+    expect(staged).toEqual([normalizePath('/foo.js'), normalizePath('/bar.js')])
   })
 
   it('should return empty array when no staged files', async () => {
@@ -20,7 +28,7 @@ describe('getStagedFiles', () => {
     execGit.mockImplementationOnce(async () => {
       throw new Error('fatal: not a git repository (or any of the parent directories): .git')
     })
-    const staged = await getStagedFiles()
+    const staged = await getStagedFiles({})
     expect(staged).toEqual(null)
   })
 })
diff --git a/test/index.spec.js b/test/index.spec.js
index 81bd519..c07c238 100644
--- a/test/index.spec.js
+++ b/test/index.spec.js
@@ -1,5 +1,3 @@
-import path from 'path'
-
 import { lilconfig } from 'lilconfig'
 import makeConsoleMock from 'consolemock'
 
@@ -7,10 +5,6 @@ jest.unmock('execa')
 
 import { getStagedFiles } from '../lib/getStagedFiles'
 import lintStaged from '../lib/index'
-import { InvalidOptionsError } from '../lib/symbols'
-import { validateOptions } from '../lib/validateOptions'
-
-import { replaceSerializer } from './utils/replaceSerializer'
 
 const mockLilConfig = (result) => {
   lilconfig.mockImplementationOnce(() => ({
@@ -76,7 +70,7 @@ describe('lintStaged', () => {
   })
 
   it('should use use the console if no logger is passed', async () => {
-    expect.assertions(2)
+    expect.assertions(1)
 
     mockLilConfig({ config: {} })
 
@@ -84,276 +78,13 @@ describe('lintStaged', () => {
     const mockedConsole = makeConsoleMock()
     console = mockedConsole
 
-    await expect(lintStaged()).rejects.toMatchInlineSnapshot(
-      `[Error: Configuration should not be empty]`
-    )
-
-    expect(mockedConsole.printHistory()).toMatchInlineSnapshot(`""`)
-
-    console = previousConsole
-  })
-
-  it('should output config in debug mode', async () => {
-    expect.assertions(1)
-
-    const config = { '*': 'mytask' }
-    mockLilConfig({ config })
-
-    await lintStaged({ debug: true, quiet: true }, logger)
-
-    expect(logger.printHistory()).toMatchInlineSnapshot(`
-      "
-      LOG Running lint-staged with the following config:
-      LOG {
-        '*': 'mytask'
-      }"
-    `)
-  })
-
-  it('should not output config in normal mode', async () => {
-    expect.assertions(1)
-
-    const config = { '*': 'mytask' }
-    mockLilConfig({ config })
-
-    await lintStaged({ quiet: true }, logger)
-
-    expect(logger.printHistory()).toMatchInlineSnapshot(`""`)
-  })
-
-  it('should throw when invalid options are provided', async () => {
-    expect.assertions(2)
-
-    validateOptions.mockImplementationOnce(async () => {
-      throw InvalidOptionsError
-    })
-
-    await expect(lintStaged({ '*': 'mytask' }, logger)).rejects.toMatchInlineSnapshot(
-      `[Error: Invalid Options]`
-    )
-
-    expect(logger.printHistory()).toMatchInlineSnapshot(`""`)
-  })
-
-  it('should throw when invalid config is provided', async () => {
-    const config = {}
-    mockLilConfig({ config })
-
-    await expect(lintStaged({ quiet: true }, logger)).rejects.toMatchInlineSnapshot(
-      `[Error: Configuration should not be empty]`
-    )
-
-    expect(logger.printHistory()).toMatchInlineSnapshot(`""`)
-  })
-
-  it('should load JSON config file', async () => {
-    expect.assertions(1)
-
-    await lintStaged(
-      {
-        configPath: path.join(__dirname, '__mocks__', 'my-config.json'),
-        debug: true,
-        quiet: true,
-      },
-      logger
-    )
-
-    expect(logger.printHistory()).toMatchInlineSnapshot(`
-      "
-      LOG Running lint-staged with the following config:
-      LOG {
-        '*': 'mytask'
-      }"
-    `)
-  })
-
-  it('should load YAML config file', async () => {
-    expect.assertions(1)
-
-    await lintStaged(
-      {
-        configPath: path.join(__dirname, '__mocks__', 'my-config.yml'),
-        debug: true,
-        quiet: true,
-      },
-      logger
-    )
-
-    expect(logger.printHistory()).toMatchInlineSnapshot(`
-      "
-      LOG Running lint-staged with the following config:
-      LOG {
-        '*': 'mytask'
-      }"
-    `)
-  })
-
-  it('should load CommonJS config file from absolute path', async () => {
-    expect.assertions(1)
-
-    await lintStaged(
-      {
-        configPath: path.join(__dirname, '__mocks__', 'advanced-config.js'),
-        debug: true,
-        quiet: true,
-      },
-      logger
-    )
-
-    expect(logger.printHistory()).toMatchInlineSnapshot(`
-      "
-      LOG Running lint-staged with the following config:
-      LOG {
-        '*.css': [Function: *.css],
-        '*.js': [Function: *.js]
-      }"
-    `)
-  })
-
-  it('should load CommonJS config file from relative path', async () => {
-    expect.assertions(1)
-
-    await lintStaged(
-      {
-        configPath: path.join('test', '__mocks__', 'advanced-config.js'),
-        debug: true,
-        quiet: true,
-      },
-      logger
-    )
-
-    expect(logger.printHistory()).toMatchInlineSnapshot(`
-      "
-      LOG Running lint-staged with the following config:
-      LOG {
-        '*.css': [Function: *.css],
-        '*.js': [Function: *.js]
-      }"
-    `)
-  })
-
-  it('should load CommonJS config file from .cjs file', async () => {
-    expect.assertions(1)
-
-    await lintStaged(
-      {
-        configPath: path.join('test', '__mocks__', 'my-config.cjs'),
-        debug: true,
-        quiet: true,
-      },
-      logger
-    )
-
-    expect(logger.printHistory()).toMatchInlineSnapshot(`
-      "
-      LOG Running lint-staged with the following config:
-      LOG {
-        '*': 'mytask'
-      }"
-    `)
-  })
-
-  it('should load EMS config file from .mjs file', async () => {
-    expect.assertions(1)
-
-    await lintStaged(
-      {
-        configPath: path.join('test', '__mocks__', 'esm-config.mjs'),
-        debug: true,
-        quiet: true,
-      },
-      logger
-    )
+    await lintStaged()
 
-    expect(logger.printHistory()).toMatchInlineSnapshot(`
+    expect(mockedConsole.printHistory()).toMatchInlineSnapshot(`
       "
-      LOG Running lint-staged with the following config:
-      LOG {
-        '*': 'mytask'
-      }"
-    `)
-  })
-
-  it('should load EMS config file from .js file', async () => {
-    expect.assertions(1)
-
-    await lintStaged(
-      {
-        configPath: path.join('test', '__mocks__', 'esm-config-in-js.js'),
-        debug: true,
-        quiet: true,
-      },
-      logger
-    )
-
-    expect(logger.printHistory()).toMatchInlineSnapshot(`
-      "
-      LOG Running lint-staged with the following config:
-      LOG {
-        '*': 'mytask'
-      }"
-    `)
-  })
-
-  it('should use config object', async () => {
-    expect.assertions(1)
-
-    const config = { '*': 'node -e "process.exit(1)"' }
-
-    await lintStaged({ config, quiet: true }, logger)
-
-    expect(logger.printHistory()).toMatchInlineSnapshot(`""`)
-  })
-
-  it('should load a CJS module when specified', async () => {
-    expect.assertions(1)
-
-    jest.mock('my-lint-staged-config')
-
-    await lintStaged({ configPath: 'my-lint-staged-config', quiet: true, debug: true }, logger)
-
-    expect(logger.printHistory()).toMatchInlineSnapshot(`
-      "
-      LOG Running lint-staged with the following config:
-      LOG {
-        '*': 'mytask'
-      }"
-    `)
-  })
-
-  it('should print helpful error message when config file is not found', async () => {
-    expect.assertions(2)
-
-    mockLilConfig(null)
-
-    await expect(lintStaged({ quiet: true }, logger)).rejects.toMatchInlineSnapshot(
-      `[Error: Configuration could not be found]`
-    )
-
-    expect(logger.printHistory()).toMatchInlineSnapshot(`
-      "
-      ERROR Configuration could not be found."
+      ERROR ✖ Failed to get staged files!"
     `)
-  })
-
-  it('should print helpful error message when explicit config file is not found', async () => {
-    expect.assertions(3)
-
-    const nonExistentConfig = 'fake-config-file.yml'
 
-    // Serialize Windows, Linux and MacOS paths consistently
-    expect.addSnapshotSerializer(
-      replaceSerializer(
-        /ENOENT: no such file or directory, open '([^']+)'/,
-        `ENOENT: no such file or directory, open '${nonExistentConfig}'`
-      )
-    )
-
-    await expect(
-      lintStaged({ configPath: nonExistentConfig, quiet: true }, logger)
-    ).rejects.toThrowError()
-
-    expect(logger.printHistory()).toMatch('ENOENT')
-    expect(logger.printHistory()).toMatch('Configuration could not be found')
+    console = previousConsole
   })
 })
diff --git a/test/integration.test.js b/test/integration.test.js
index d044a3a..cefe6e3 100644
--- a/test/integration.test.js
+++ b/test/integration.test.js
@@ -7,6 +7,7 @@ import normalize from 'normalize-path'
 
 jest.unmock('lilconfig')
 jest.unmock('execa')
+
 jest.mock('../lib/resolveConfig', () => ({
   /** Unfortunately necessary due to non-ESM tests. */
   resolveConfig: (configPath) => {
@@ -18,6 +19,11 @@ jest.mock('../lib/resolveConfig', () => ({
   },
 }))
 
+jest.mock('../lib/dynamicImport', () => ({
+  // 'pathToFileURL' is not supported with Jest + Babel
+  dynamicImport: jest.fn().mockImplementation(async (input) => require(input)),
+}))
+
 import { execGit as execGitBase } from '../lib/execGit'
 import lintStaged from '../lib/index'
 
@@ -27,6 +33,30 @@ import { isWindowsActions, normalizeWindowsNewlines } from './utils/crossPlatfor
 
 jest.setTimeout(20000)
 
+// Replace path like `../../git/lint-staged` with `<path>/lint-staged`
+const replaceConfigPathSerializer = replaceSerializer(
+  /((?:\.\.\/)+).*\/lint-staged/gm,
+  `<path>/lint-staged`
+)
+
+// Hide filepath from test snapshot because it's not important and varies in CI
+const replaceFilepathSerializer = replaceSerializer(
+  /prettier --write (.*)?$/gm,
+  `prettier --write <path>`
+)
+
+// Awkwardly merge three serializers
+expect.addSnapshotSerializer({
+  test: (val) =>
+    ansiSerializer.test(val) ||
+    replaceConfigPathSerializer.test(val) ||
+    replaceFilepathSerializer.test(val),
+  print: (val, serialize) =>
+    replaceFilepathSerializer.print(
+      replaceConfigPathSerializer.print(ansiSerializer.print(val, serialize))
+    ),
+})
+
 const testJsFilePretty = `module.exports = {
   foo: "bar",
 };
@@ -46,19 +76,28 @@ const fixJsConfig = { config: { '*.js': 'prettier --write' } }
 let tmpDir
 let cwd
 
+const ensureDir = async (inputPath) => fs.ensureDir(path.dirname(inputPath))
+
 // Get file content, coercing Windows `\r\n` newlines to `\n`
 const readFile = async (filename, dir = cwd) => {
-  const file = await fs.readFile(path.resolve(dir, filename), { encoding: 'utf-8' })
+  const filepath = path.isAbsolute(filename) ? filename : path.join(dir, filename)
+  const file = await fs.readFile(filepath, { encoding: 'utf-8' })
   return normalizeWindowsNewlines(file)
 }
 
 // Append to file, creating if it doesn't exist
-const appendFile = async (filename, content, dir = cwd) =>
-  fs.appendFile(path.resolve(dir, filename), content)
+const appendFile = async (filename, content, dir = cwd) => {
+  const filepath = path.isAbsolute(filename) ? filename : path.join(dir, filename)
+  await ensureDir(filepath)
+  await fs.appendFile(filepath, content)
+}
 
 // Write (over) file, creating if it doesn't exist
-const writeFile = async (filename, content, dir = cwd) =>
-  fs.writeFile(path.resolve(dir, filename), content)
+const writeFile = async (filename, content, dir = cwd) => {
+  const filepath = path.isAbsolute(filename) ? filename : path.join(dir, filename)
+  await ensureDir(filepath)
+  fs.writeFile(filepath, content)
+}
 
 // Wrap execGit to always pass `gitOps`
 const execGit = async (args, options = {}) => execGitBase(args, { cwd, ...options })
@@ -253,22 +292,24 @@ describe('lint-staged', () => {
 
     expect(console.printHistory()).toMatchInlineSnapshot(`
       "
-      LOG [STARTED] Preparing...
-      LOG [SUCCESS] Preparing...
+      LOG [STARTED] Preparing lint-staged...
+      LOG [SUCCESS] Preparing lint-staged...
       LOG [STARTED] Hiding unstaged changes to partially staged files...
       LOG [SUCCESS] Hiding unstaged changes to partially staged files...
-      LOG [STARTED] Running tasks...
-      LOG [STARTED] Running tasks for *.js
+      LOG [STARTED] Running tasks for staged files...
+      LOG [STARTED] <path>/lint-staged — 1 file
+      LOG [STARTED] *.js — 1 file
       LOG [STARTED] prettier --list-different
       LOG [SUCCESS] prettier --list-different
-      LOG [SUCCESS] Running tasks for *.js
-      LOG [SUCCESS] Running tasks...
-      LOG [STARTED] Applying modifications...
-      LOG [SUCCESS] Applying modifications...
+      LOG [SUCCESS] *.js — 1 file
+      LOG [SUCCESS] <path>/lint-staged — 1 file
+      LOG [SUCCESS] Running tasks for staged files...
+      LOG [STARTED] Applying modifications from tasks...
+      LOG [SUCCESS] Applying modifications from tasks...
       LOG [STARTED] Restoring unstaged changes to partially staged files...
       LOG [SUCCESS] Restoring unstaged changes to partially staged files...
-      LOG [STARTED] Cleaning up...
-      LOG [SUCCESS] Cleaning up..."
+      LOG [STARTED] Cleaning up temporary files...
+      LOG [SUCCESS] Cleaning up temporary files..."
     `)
 
     // Nothing is wrong, so a new commit is created and file is pretty
@@ -746,17 +787,19 @@ describe('lint-staged', () => {
 
     expect(console.printHistory()).toMatchInlineSnapshot(`
       "
-      LOG [STARTED] Preparing...
-      LOG [SUCCESS] Preparing...
-      LOG [STARTED] Running tasks...
-      LOG [STARTED] Running tasks for *.js
+      LOG [STARTED] Preparing lint-staged...
+      LOG [SUCCESS] Preparing lint-staged...
+      LOG [STARTED] Running tasks for staged files...
+      LOG [STARTED] <path>/lint-staged — 1 file
+      LOG [STARTED] *.js — 1 file
       LOG [STARTED] git stash drop
       LOG [SUCCESS] git stash drop
-      LOG [SUCCESS] Running tasks for *.js
-      LOG [SUCCESS] Running tasks...
-      LOG [STARTED] Applying modifications...
-      LOG [SUCCESS] Applying modifications...
-      LOG [STARTED] Cleaning up...
+      LOG [SUCCESS] *.js — 1 file
+      LOG [SUCCESS] <path>/lint-staged — 1 file
+      LOG [SUCCESS] Running tasks for staged files...
+      LOG [STARTED] Applying modifications from tasks...
+      LOG [SUCCESS] Applying modifications from tasks...
+      LOG [STARTED] Cleaning up temporary files...
       ERROR [FAILED] lint-staged automatic backup is missing!"
     `)
   })
@@ -784,22 +827,24 @@ describe('lint-staged', () => {
       "
       WARN ⚠ Some of your tasks use \`git add\` command. Please remove it from the config since all modifications made by tasks will be automatically added to the git commit index.
 
-      LOG [STARTED] Preparing...
-      LOG [SUCCESS] Preparing...
-      LOG [STARTED] Running tasks...
-      LOG [STARTED] Running tasks for *.js
+      LOG [STARTED] Preparing lint-staged...
+      LOG [SUCCESS] Preparing lint-staged...
+      LOG [STARTED] Running tasks for staged files...
+      LOG [STARTED] <path>/lint-staged — 1 file
+      LOG [STARTED] *.js — 1 file
       LOG [STARTED] prettier --write
       LOG [SUCCESS] prettier --write
       LOG [STARTED] git add
       LOG [SUCCESS] git add
-      LOG [SUCCESS] Running tasks for *.js
-      LOG [SUCCESS] Running tasks...
-      LOG [STARTED] Applying modifications...
+      LOG [SUCCESS] *.js — 1 file
+      LOG [SUCCESS] <path>/lint-staged — 1 file
+      LOG [SUCCESS] Running tasks for staged files...
+      LOG [STARTED] Applying modifications from tasks...
       ERROR [FAILED] Prevented an empty git commit!
       LOG [STARTED] Reverting to original state because of errors...
       LOG [SUCCESS] Reverting to original state because of errors...
-      LOG [STARTED] Cleaning up...
-      LOG [SUCCESS] Cleaning up...
+      LOG [STARTED] Cleaning up temporary files...
+      LOG [SUCCESS] Cleaning up temporary files...
       WARN 
         ⚠ lint-staged prevented an empty git commit.
         Use the --allow-empty option to continue, or check your task configuration
@@ -932,16 +977,18 @@ describe('lint-staged', () => {
       "
       WARN ⚠ Skipping backup because \`--no-stash\` was used.
 
-      LOG [STARTED] Preparing...
-      LOG [SUCCESS] Preparing...
-      LOG [STARTED] Running tasks...
-      LOG [STARTED] Running tasks for *.js
+      LOG [STARTED] Preparing lint-staged...
+      LOG [SUCCESS] Preparing lint-staged...
+      LOG [STARTED] Running tasks for staged files...
+      LOG [STARTED] <path>/lint-staged — 1 file
+      LOG [STARTED] *.js — 1 file
       LOG [STARTED] prettier --write
       LOG [SUCCESS] prettier --write
-      LOG [SUCCESS] Running tasks for *.js
-      LOG [SUCCESS] Running tasks...
-      LOG [STARTED] Applying modifications...
-      LOG [SUCCESS] Applying modifications..."
+      LOG [SUCCESS] *.js — 1 file
+      LOG [SUCCESS] <path>/lint-staged — 1 file
+      LOG [SUCCESS] Running tasks for staged files...
+      LOG [STARTED] Applying modifications from tasks...
+      LOG [SUCCESS] Applying modifications from tasks..."
     `)
 
     // Nothing is wrong, so a new commit is created
@@ -969,35 +1016,24 @@ describe('lint-staged', () => {
       })
     ).rejects.toThrowError()
 
-    // Hide filepath from test snapshot because it's not important and varies in CI
-    const replaceFilepathSerializer = replaceSerializer(
-      /prettier --write (.*)?$/gm,
-      `prettier --write FILEPATH`
-    )
-
-    // Awkwardly merge two serializers
-    expect.addSnapshotSerializer({
-      test: (val) => ansiSerializer.test(val) || replaceFilepathSerializer.test(val),
-      print: (val, serialize) =>
-        replaceFilepathSerializer.print(ansiSerializer.print(val, serialize)),
-    })
-
     expect(console.printHistory()).toMatchInlineSnapshot(`
       "
       WARN ⚠ Skipping backup because \`--no-stash\` was used.
 
-      LOG [STARTED] Preparing...
-      LOG [SUCCESS] Preparing...
+      LOG [STARTED] Preparing lint-staged...
+      LOG [SUCCESS] Preparing lint-staged...
       LOG [STARTED] Hiding unstaged changes to partially staged files...
       LOG [SUCCESS] Hiding unstaged changes to partially staged files...
-      LOG [STARTED] Running tasks...
-      LOG [STARTED] Running tasks for *.js
-      LOG [STARTED] prettier --write FILEPATH
-      LOG [SUCCESS] prettier --write FILEPATH
-      LOG [SUCCESS] Running tasks for *.js
-      LOG [SUCCESS] Running tasks...
-      LOG [STARTED] Applying modifications...
-      LOG [SUCCESS] Applying modifications...
+      LOG [STARTED] Running tasks for staged files...
+      LOG [STARTED] <path>/lint-staged — 1 file
+      LOG [STARTED] *.js — 1 file
+      LOG [STARTED] prettier --write <path>
+      LOG [SUCCESS] prettier --write <path>
+      LOG [SUCCESS] *.js — 1 file
+      LOG [SUCCESS] <path>/lint-staged — 1 file
+      LOG [SUCCESS] Running tasks for staged files...
+      LOG [STARTED] Applying modifications from tasks...
+      LOG [SUCCESS] Applying modifications from tasks...
       LOG [STARTED] Restoring unstaged changes to partially staged files...
       ERROR [FAILED] Unstaged changes could not be restored due to a merge conflict!
       ERROR 
@@ -1074,6 +1110,43 @@ describe('lint-staged', () => {
     expect(await readFile('test.js')).toEqual(testJsFilePretty)
     expect(await readFile('test2.js')).toEqual(testJsFilePretty)
   })
+
+  it('should support multiple configuration files', async () => {
+    // Add some empty files
+    await writeFile('file.js', '')
+    await writeFile('deeper/file.js', '')
+    await writeFile('deeper/even/file.js', '')
+    await writeFile('deeper/even/deeper/file.js', '')
+    await writeFile('a/very/deep/file/path/file.js', '')
+
+    const echoJSConfig = (echo) =>
+      `module.exports = { '*.js': (files) => files.map((f) => \`echo ${echo} > \${f}\`) }`
+
+    await writeFile('.lintstagedrc.js', echoJSConfig('level-0'))
+    await writeFile('deeper/.lintstagedrc.js', echoJSConfig('level-1'))
+    await writeFile('deeper/even/.lintstagedrc.cjs', echoJSConfig('level-2'))
+
+    // Stage all files
+    await execGit(['add', '.'])
+
+    // Run lint-staged with `--shell` so that tasks do their thing
+    await gitCommit({ shell: true })
+
+    // 'file.js' matched '.lintstagedrc.json'
+    expect(await readFile('file.js')).toMatch('level-0')
+
+    // 'deeper/file.js' matched 'deeper/.lintstagedrc.json'
+    expect(await readFile('deeper/file.js')).toMatch('level-1')
+
+    // 'deeper/even/file.js' matched 'deeper/even/.lintstagedrc.json'
+    expect(await readFile('deeper/even/file.js')).toMatch('level-2')
+
+    // 'deeper/even/deeper/file.js' matched from parent 'deeper/even/.lintstagedrc.json'
+    expect(await readFile('deeper/even/deeper/file.js')).toMatch('level-2')
+
+    // 'a/very/deep/file/path/file.js' matched '.lintstagedrc.json'
+    expect(await readFile('a/very/deep/file/path/file.js')).toMatch('level-0')
+  })
 })
 
 describe('lintStaged', () => {
@@ -1103,16 +1176,18 @@ describe('lintStaged', () => {
       "
       WARN ⚠ Skipping backup because there’s no initial commit yet.
 
-      LOG [STARTED] Preparing...
-      LOG [SUCCESS] Preparing...
-      LOG [STARTED] Running tasks...
-      LOG [STARTED] Running tasks for *.js
+      LOG [STARTED] Preparing lint-staged...
+      LOG [SUCCESS] Preparing lint-staged...
+      LOG [STARTED] Running tasks for staged files...
+      LOG [STARTED] <path>/lint-staged — 1 file
+      LOG [STARTED] *.js — 1 file
       LOG [STARTED] prettier --list-different
       LOG [SUCCESS] prettier --list-different
-      LOG [SUCCESS] Running tasks for *.js
-      LOG [SUCCESS] Running tasks...
-      LOG [STARTED] Applying modifications...
-      LOG [SUCCESS] Applying modifications..."
+      LOG [SUCCESS] *.js — 1 file
+      LOG [SUCCESS] <path>/lint-staged — 1 file
+      LOG [SUCCESS] Running tasks for staged files...
+      LOG [STARTED] Applying modifications from tasks...
+      LOG [SUCCESS] Applying modifications from tasks..."
     `)
 
     // Nothing is wrong, so the initial commit is created
diff --git a/test/loadConfig.spec.js b/test/loadConfig.spec.js
index 2123012..2a533a1 100644
--- a/test/loadConfig.spec.js
+++ b/test/loadConfig.spec.js
@@ -1,3 +1,9 @@
+import path from 'path'
+
+import makeConsoleMock from 'consolemock'
+
+import { loadConfig } from '../lib/loadConfig'
+
 jest.mock('../lib/resolveConfig', () => ({
   /** Unfortunately necessary due to non-ESM tests. */
   resolveConfig: (configPath) => {
@@ -9,10 +15,170 @@ jest.mock('../lib/resolveConfig', () => ({
   },
 }))
 
-import { dynamicImport } from '../lib/loadConfig.js'
+jest.unmock('execa')
+
+/**
+ * This converts paths into `file://` urls, but this doesn't
+ * work with `import()` when using babel + jest.
+ */
+jest.mock('url', () => ({
+  pathToFileURL: (path) => path,
+}))
+
+// TODO: Never run tests in the project's WC because this might change source files git status
+
+describe('loadConfig', () => {
+  const logger = makeConsoleMock()
+
+  beforeEach(() => {
+    logger.clearHistory()
+  })
+
+  it('should load JSON config file', async () => {
+    expect.assertions(1)
+
+    const { config } = await loadConfig(
+      { configPath: path.join(__dirname, '__mocks__', 'my-config.json') },
+      logger
+    )
+
+    expect(config).toMatchInlineSnapshot(`
+      Object {
+        "*": "mytask",
+      }
+    `)
+  })
+
+  it('should load YAML config file', async () => {
+    expect.assertions(1)
+
+    const { config } = await loadConfig(
+      { configPath: path.join(__dirname, '__mocks__', 'my-config.yml') },
+      logger
+    )
+
+    expect(config).toMatchInlineSnapshot(`
+      Object {
+        "*": "mytask",
+      }
+    `)
+  })
+
+  it('should load CommonJS config file from absolute path', async () => {
+    expect.assertions(1)
+
+    const { config } = await loadConfig(
+      { configPath: path.join(__dirname, '__mocks__', 'advanced-config.js') },
+      logger
+    )
+
+    expect(config).toMatchInlineSnapshot(`
+      Object {
+        "*.css": [Function],
+        "*.js": [Function],
+      }
+    `)
+  })
+
+  it('should load CommonJS config file from relative path', async () => {
+    expect.assertions(1)
+
+    const { config } = await loadConfig(
+      { configPath: path.join('test', '__mocks__', 'advanced-config.js') },
+      logger
+    )
+
+    expect(config).toMatchInlineSnapshot(`
+      Object {
+        "*.css": [Function],
+        "*.js": [Function],
+      }
+    `)
+  })
+
+  it('should load CommonJS config file from .cjs file', async () => {
+    expect.assertions(1)
+
+    const { config } = await loadConfig(
+      { configPath: path.join('test', '__mocks__', 'my-config.cjs') },
+      logger
+    )
+
+    expect(config).toMatchInlineSnapshot(`
+      Object {
+        "*": "mytask",
+      }
+    `)
+  })
+
+  it('should load EMS config file from .mjs file', async () => {
+    expect.assertions(1)
+
+    const { config } = await loadConfig(
+      {
+        configPath: path.join('test', '__mocks__', 'esm-config.mjs'),
+        debug: true,
+        quiet: true,
+      },
+      logger
+    )
+
+    expect(config).toMatchInlineSnapshot(`
+      Object {
+        "*": "mytask",
+      }
+    `)
+  })
+
+  it('should load EMS config file from .js file', async () => {
+    expect.assertions(1)
+
+    const { config } = await loadConfig(
+      {
+        configPath: path.join('test', '__mocks__', 'esm-config-in-js.js'),
+        debug: true,
+        quiet: true,
+      },
+      logger
+    )
+
+    expect(config).toMatchInlineSnapshot(`
+      Object {
+        "*": "mytask",
+      }
+    `)
+  })
+
+  it('should load a CJS module when specified', async () => {
+    expect.assertions(1)
+
+    jest.mock('my-lint-staged-config')
+
+    const { config } = await loadConfig(
+      { configPath: 'my-lint-staged-config', quiet: true, debug: true },
+      logger
+    )
+
+    expect(config).toMatchInlineSnapshot(`
+      Object {
+        "*": "mytask",
+      }
+    `)
+  })
+
+  it('should return empty object when config file is not found', async () => {
+    expect.assertions(1)
+
+    const result = await loadConfig({ cwd: '/' })
+
+    expect(result).toMatchInlineSnapshot(`Object {}`)
+  })
+
+  it('should return empty object when explicit config file is not found', async () => {
+    expect.assertions(1)
+
+    const result = await loadConfig({ configPath: 'fake-config-file.yml' }, logger)
 
-describe('dynamicImport', () => {
-  it('should log errors into console', () => {
-    expect(() => dynamicImport('not-found.js')).rejects.toThrowError(`Cannot find module`)
+    expect(result).toMatchInlineSnapshot(`Object {}`)
   })
 })
diff --git a/test/runAll.spec.js b/test/runAll.spec.js
index 32404eb..9560638 100644
--- a/test/runAll.spec.js
+++ b/test/runAll.spec.js
@@ -15,6 +15,17 @@ jest.mock('../lib/getStagedFiles')
 jest.mock('../lib/gitWorkflow')
 jest.mock('../lib/resolveGitRepo')
 
+jest.mock('../lib/resolveConfig', () => ({
+  /** Unfortunately necessary due to non-ESM tests. */
+  resolveConfig: (configPath) => {
+    try {
+      return require.resolve(configPath)
+    } catch {
+      return configPath
+    }
+  },
+}))
+
 getStagedFiles.mockImplementation(async () => [])
 
 resolveGitRepo.mockImplementation(async () => {
@@ -22,6 +33,8 @@ resolveGitRepo.mockImplementation(async () => {
   return { gitConfigDir: normalize(path.resolve(cwd, '.git')), gitDir: normalize(cwd) }
 })
 
+const configPath = '.lintstagedrc.json'
+
 describe('runAll', () => {
   const globalConsoleTemp = console
 
@@ -39,7 +52,38 @@ describe('runAll', () => {
 
   it('should resolve the promise with no tasks', async () => {
     expect.assertions(1)
-    await expect(runAll({ config: {} })).resolves.toMatchInlineSnapshot(`
+    await expect(runAll({ configObject: {}, configPath })).resolves.toMatchInlineSnapshot(`
+            Object {
+              "errors": Set {},
+              "hasPartiallyStagedFiles": null,
+              "output": Array [
+                "→ No staged files found.",
+              ],
+              "quiet": false,
+              "shouldBackup": true,
+            }
+          `)
+  })
+
+  it('should throw when failed to find staged files', async () => {
+    expect.assertions(1)
+    getStagedFiles.mockImplementationOnce(async () => null)
+    await expect(
+      runAll({ configObject: {}, configPath })
+    ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`)
+  })
+
+  it('should throw when failed to find staged files and quiet', async () => {
+    expect.assertions(1)
+    getStagedFiles.mockImplementationOnce(async () => null)
+    await expect(
+      runAll({ configObject: {}, configPath, quiet: true })
+    ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`)
+  })
+
+  it('should print output when no staged files', async () => {
+    expect.assertions(1)
+    await expect(runAll({ configObject: {}, configPath })).resolves.toMatchInlineSnapshot(`
             Object {
               "errors": Set {},
               "hasPartiallyStagedFiles": null,
@@ -54,7 +98,8 @@ describe('runAll', () => {
 
   it('should not print output when no staged files and quiet', async () => {
     expect.assertions(1)
-    await expect(runAll({ config: {}, quiet: true })).resolves.toMatchInlineSnapshot(`
+    await expect(runAll({ configObject: {}, configPath, quiet: true })).resolves
+      .toMatchInlineSnapshot(`
             Object {
               "errors": Set {},
               "hasPartiallyStagedFiles": null,
@@ -67,42 +112,44 @@ describe('runAll', () => {
 
   it('should resolve the promise with no files', async () => {
     expect.assertions(1)
-    await runAll({ config: { '*.js': ['echo "sample"'] } })
+    await runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath })
     expect(console.printHistory()).toMatchInlineSnapshot(`""`)
   })
 
   it('should use an injected logger', async () => {
     expect.assertions(1)
     const logger = makeConsoleMock()
-    await runAll({ config: { '*.js': ['echo "sample"'] }, debug: true }, logger)
+    await runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath, debug: true }, logger)
     expect(logger.printHistory()).toMatchInlineSnapshot(`""`)
   })
 
   it('should exit without output when no staged files match configured tasks and quiet', async () => {
     expect.assertions(1)
     getStagedFiles.mockImplementationOnce(async () => ['sample.js'])
-    await runAll({ config: { '*.css': ['echo "sample"'] }, quiet: true })
+    await runAll({ configObject: { '*.css': ['echo "sample"'] }, configPath, quiet: true })
     expect(console.printHistory()).toMatchInlineSnapshot(`""`)
   })
 
   it('should not skip tasks if there are files', async () => {
     expect.assertions(1)
     getStagedFiles.mockImplementationOnce(async () => ['sample.js'])
-    await runAll({ config: { '*.js': ['echo "sample"'] } })
+    await runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath })
     expect(console.printHistory()).toMatchInlineSnapshot(`
       "
-      LOG [STARTED] Preparing...
-      LOG [SUCCESS] Preparing...
-      LOG [STARTED] Running tasks...
-      LOG [STARTED] Running tasks for *.js
+      LOG [STARTED] Preparing lint-staged...
+      LOG [SUCCESS] Preparing lint-staged...
+      LOG [STARTED] Running tasks for staged files...
+      LOG [STARTED]  — 1 file
+      LOG [STARTED] *.js — 1 file
       LOG [STARTED] echo \\"sample\\"
       LOG [SUCCESS] echo \\"sample\\"
-      LOG [SUCCESS] Running tasks for *.js
-      LOG [SUCCESS] Running tasks...
-      LOG [STARTED] Applying modifications...
-      LOG [SUCCESS] Applying modifications...
-      LOG [STARTED] Cleaning up...
-      LOG [SUCCESS] Cleaning up..."
+      LOG [SUCCESS] *.js — 1 file
+      LOG [SUCCESS]  — 1 file
+      LOG [SUCCESS] Running tasks for staged files...
+      LOG [STARTED] Applying modifications from tasks...
+      LOG [SUCCESS] Applying modifications from tasks...
+      LOG [STARTED] Cleaning up temporary files...
+      LOG [SUCCESS] Cleaning up temporary files..."
     `)
   })
 
@@ -118,19 +165,19 @@ describe('runAll', () => {
     }))
 
     await expect(
-      runAll({ config: { '*.js': ['echo "sample"'] } })
+      runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath })
     ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`)
 
     expect(console.printHistory()).toMatchInlineSnapshot(`
       "
-      LOG [STARTED] Preparing...
+      LOG [STARTED] Preparing lint-staged...
       ERROR [FAILED] test
-      LOG [STARTED] Running tasks...
-      INFO [SKIPPED] Skipped because of previous git error.
-      LOG [STARTED] Applying modifications...
+      LOG [STARTED] Running tasks for staged files...
+      INFO [SKIPPED] Running tasks for staged files...
+      LOG [STARTED] Applying modifications from tasks...
       INFO [SKIPPED] 
       [SKIPPED]   ✖ lint-staged failed due to a git error.
-      LOG [STARTED] Cleaning up...
+      LOG [STARTED] Cleaning up temporary files...
       INFO [SKIPPED] 
       [SKIPPED]   ✖ lint-staged failed due to a git error."
     `)
@@ -150,24 +197,26 @@ describe('runAll', () => {
     )
 
     await expect(
-      runAll({ config: { '*.js': ['echo "sample"'] } })
+      runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath })
     ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`)
 
     expect(console.printHistory()).toMatchInlineSnapshot(`
       "
-      LOG [STARTED] Preparing...
-      LOG [SUCCESS] Preparing...
-      LOG [STARTED] Running tasks...
-      LOG [STARTED] Running tasks for *.js
+      LOG [STARTED] Preparing lint-staged...
+      LOG [SUCCESS] Preparing lint-staged...
+      LOG [STARTED] Running tasks for staged files...
+      LOG [STARTED]  — 1 file
+      LOG [STARTED] *.js — 1 file
       LOG [STARTED] echo \\"sample\\"
       ERROR [FAILED] echo \\"sample\\" [1]
-      LOG [SUCCESS] Running tasks...
-      LOG [STARTED] Applying modifications...
+      ERROR [FAILED] echo \\"sample\\" [1]
+      LOG [SUCCESS] Running tasks for staged files...
+      LOG [STARTED] Applying modifications from tasks...
       INFO [SKIPPED] Skipped because of errors from tasks.
       LOG [STARTED] Reverting to original state because of errors...
       LOG [SUCCESS] Reverting to original state because of errors...
-      LOG [STARTED] Cleaning up...
-      LOG [SUCCESS] Cleaning up..."
+      LOG [STARTED] Cleaning up temporary files...
+      LOG [SUCCESS] Cleaning up temporary files..."
     `)
   })
 
@@ -187,24 +236,26 @@ describe('runAll', () => {
     )
 
     await expect(
-      runAll({ config: { '*.js': ['echo "sample"'] } })
+      runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath })
     ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`)
 
     expect(console.printHistory()).toMatchInlineSnapshot(`
       "
-      LOG [STARTED] Preparing...
-      LOG [SUCCESS] Preparing...
-      LOG [STARTED] Running tasks...
-      LOG [STARTED] Running tasks for *.js
+      LOG [STARTED] Preparing lint-staged...
+      LOG [SUCCESS] Preparing lint-staged...
+      LOG [STARTED] Running tasks for staged files...
+      LOG [STARTED]  — 1 file
+      LOG [STARTED] *.js — 1 file
       LOG [STARTED] echo \\"sample\\"
       ERROR [FAILED] echo \\"sample\\" [SIGINT]
-      LOG [SUCCESS] Running tasks...
-      LOG [STARTED] Applying modifications...
+      ERROR [FAILED] echo \\"sample\\" [SIGINT]
+      LOG [SUCCESS] Running tasks for staged files...
+      LOG [STARTED] Applying modifications from tasks...
       INFO [SKIPPED] Skipped because of errors from tasks.
       LOG [STARTED] Reverting to original state because of errors...
       LOG [SUCCESS] Reverting to original state because of errors...
-      LOG [STARTED] Cleaning up...
-      LOG [SUCCESS] Cleaning up..."
+      LOG [STARTED] Cleaning up temporary files...
+      LOG [SUCCESS] Cleaning up temporary files..."
     `)
   })
 
@@ -226,7 +277,13 @@ describe('runAll', () => {
     try {
       // Run lint-staged in `innerCwd` with relative option
       // This means the sample task will receive `foo.js`
-      await runAll({ config: { '*.js': mockTask }, stash: false, relative: true, cwd: innerCwd })
+      await runAll({
+        configObject: { '*.js': mockTask },
+        configPath,
+        stash: false,
+        relative: true,
+        cwd: innerCwd,
+      })
     } catch {} // eslint-disable-line no-empty
 
     // task received relative `foo.js`
diff --git a/test/validateConfig.spec.js b/test/validateConfig.spec.js
index b828df6..970bd5c 100644
--- a/test/validateConfig.spec.js
+++ b/test/validateConfig.spec.js
@@ -2,6 +2,8 @@ import makeConsoleMock from 'consolemock'
 
 import { validateConfig } from '../lib/validateConfig'
 
+const configPath = '.lintstagedrc.json'
+
 describe('validateConfig', () => {
   let logger
 
@@ -12,7 +14,7 @@ describe('validateConfig', () => {
   it('should throw and should print validation errors for invalid config 1', () => {
     const invalidConfig = 'test'
 
-    expect(() => validateConfig(invalidConfig, logger)).toThrowErrorMatchingSnapshot()
+    expect(() => validateConfig(invalidConfig, configPath, logger)).toThrowErrorMatchingSnapshot()
   })
 
   it('should throw and should print validation errors for invalid config', () => {
@@ -20,13 +22,17 @@ describe('validateConfig', () => {
       foo: false,
     }
 
-    expect(() => validateConfig(invalidConfig, logger)).toThrowErrorMatchingSnapshot()
+    expect(() => validateConfig(invalidConfig, configPath, logger)).toThrowErrorMatchingSnapshot()
+  })
+
+  it('should throw for empty config', () => {
+    expect(() => validateConfig({}, configPath, logger)).toThrowErrorMatchingSnapshot()
   })
 
   it('should wrap function config into object', () => {
     const functionConfig = (stagedFiles) => [`eslint --fix ${stagedFiles}', 'git add`]
 
-    expect(validateConfig(functionConfig, logger)).toEqual({
+    expect(validateConfig(functionConfig, configPath, logger)).toEqual({
       '*': functionConfig,
     })
     expect(logger.printHistory()).toEqual('')
@@ -37,7 +43,7 @@ describe('validateConfig', () => {
       '*.js': ['eslint --fix', 'git add'],
     }
 
-    expect(() => validateConfig(validSimpleConfig, logger)).not.toThrow()
+    expect(() => validateConfig(validSimpleConfig, configPath, logger)).not.toThrow()
     expect(logger.printHistory()).toEqual('')
   })
 
@@ -50,7 +56,7 @@ describe('validateConfig', () => {
       '*.css': [(filenames) => filenames.map((filename) => `eslint --fix ${filename}`)],
     }
 
-    expect(() => validateConfig(functionTask, logger)).not.toThrow()
+    expect(() => validateConfig(functionTask, configPath, logger)).not.toThrow()
     expect(logger.printHistory()).toEqual('')
   })
 
@@ -68,7 +74,7 @@ describe('validateConfig', () => {
       subTaskConcurrency: 10,
     }
 
-    expect(() => validateConfig(advancedConfig, logger)).toThrowErrorMatchingSnapshot()
+    expect(() => validateConfig(advancedConfig, configPath, logger)).toThrowErrorMatchingSnapshot()
     expect(logger.printHistory()).toMatchSnapshot()
   })
 
@@ -77,7 +83,7 @@ describe('validateConfig', () => {
       concurrent: 'my command',
     }
 
-    expect(() => validateConfig(stillValidConfig, logger)).not.toThrow()
+    expect(() => validateConfig(stillValidConfig, configPath, logger)).not.toThrow()
     expect(logger.printHistory()).toEqual('')
   })
 })