Repository: okonet/lint-staged
Pull request #1133 (2022-04-09 10:01:55)

fix: try to discover configs only from inside cwd
This PR does a bit of refactoring to remove the previous `getConfigGroups` function and merge its functionality into the more robust `searchConfig`.

It also fixes an issue where if your `cwd` was inside a git repo with multiple _lint-staged_ configs, it would still discover all configs from the repo. Now the logic is closer to expected:

1. find all configs from inside the `cwd` only
2. if there are no configs found, try to auto-discover from `cwd` using `lilconfig`
    - this matches the previous pre-monorepo functionality
 3. if there are still no configs, throw error

Fixes https://github.com/okonet/lint-staged/issues/1132

codecov[bot]: # [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/1133?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) Report
> Merging [#1133](https://codecov.io/gh/okonet/lint-staged/pull/1133?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) (ff7052b) into [master](https://codecov.io/gh/okonet/lint-staged/commit/d327873b1c0b6fbdeb6fd276e523043d51d6de37?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) (d327873) will **decrease** coverage by `1.27%`.
> The diff coverage is `84.48%`.

```diff
@@             Coverage Diff             @@
##            master    #1133      +/-   ##
===========================================
- Coverage   100.00%   98.72%   -1.28%     
===========================================
  Files           25       25              
  Lines          700      704       +4     
  Branches       182      180       -2     
===========================================
- Hits           700      695       -5     
- Misses           0        8       +8     
- Partials         0        1       +1     
```


| [Impacted Files](https://codecov.io/gh/okonet/lint-staged/pull/1133?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/1133/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL2luZGV4Lmpz) | `97.05% <66.66%> (-2.95%)` | :arrow_down: |
| [lib/searchConfigs.js](https://codecov.io/gh/okonet/lint-staged/pull/1133/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL3NlYXJjaENvbmZpZ3MuanM=) | `82.22% <68.00%> (-17.78%)` | :arrow_down: |
| [lib/groupFilesByConfig.js](https://codecov.io/gh/okonet/lint-staged/pull/1133/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL2dyb3VwRmlsZXNCeUNvbmZpZy5qcw==) | `100.00% <100.00%> (ø)` | |
| [lib/messages.js](https://codecov.io/gh/okonet/lint-staged/pull/1133/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov#diff-bGliL21lc3NhZ2VzLmpz) | `100.00% <100.00%> (ø)` | |
| [lib/runAll.js](https://codecov.io/gh/okonet/lint-staged/pull/1133/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%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/okonet/lint-staged/pull/1133?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/1133?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov). Last update [d327873...ff7052b](https://codecov.io/gh/okonet/lint-staged/pull/1133?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: I also added in a fix for the parent globs not working since 12.2.0, in commit https://github.com/okonet/lint-staged/pull/1133/commits/90bd23a66c0716ee55fcc71d728734c4946175ee.

What do you think @okonet?

In theory we could improve the parent glob" handling, but IMO the feature is now deprecated with support for multiple config files...
iiroj: Also lifted the test coverage back to 100% on all measurements.

Patch:
diff --git a/bin/lint-staged.js b/bin/lint-staged.js
index 9a6f6eb..de0ca6e 100755
--- a/bin/lint-staged.js
+++ b/bin/lint-staged.js
@@ -34,6 +34,7 @@ cmdline
   .option('-c, --config [path]', 'path to configuration file, or - to read from stdin')
   .option('--cwd [path]', 'run all tasks in specific directory, instead of the current')
   .option('-d, --debug', 'print additional debug information', false)
+  .option('--max-arg-length', 'maximum length of the command-line argument string')
   .option('--no-stash', 'disable the backup stash, and do not revert in case of errors', false)
   .option('-q, --quiet', 'disable lint-staged’s own console output', false)
   .option('-r, --relative', 'pass relative filepaths to tasks', false)
@@ -54,31 +55,13 @@ if (cmdlineOptions.debug) {
 const debugLog = debug('lint-staged:bin')
 debugLog('Running `lint-staged@%s`', version)
 
-/**
- * Get the maximum length of a command-line argument string based on current platform
- *
- * https://serverfault.com/questions/69430/what-is-the-maximum-length-of-a-command-line-in-mac-os-x
- * https://support.microsoft.com/en-us/help/830473/command-prompt-cmd-exe-command-line-string-limitation
- * https://unix.stackexchange.com/a/120652
- */
-const getMaxArgLength = () => {
-  switch (process.platform) {
-    case 'darwin':
-      return 262144
-    case 'win32':
-      return 8191
-    default:
-      return 131072
-  }
-}
-
 const options = {
   allowEmpty: !!cmdlineOptions.allowEmpty,
   concurrent: JSON.parse(cmdlineOptions.concurrent),
   configPath: cmdlineOptions.config,
   cwd: cmdlineOptions.cwd,
   debug: !!cmdlineOptions.debug,
-  maxArgLength: getMaxArgLength() / 2,
+  maxArgLength: JSON.parse(cmdlineOptions.maxArgLength || null),
   quiet: !!cmdlineOptions.quiet,
   relative: !!cmdlineOptions.relative,
   shell: cmdlineOptions.shell /* Either a boolean or a string pointing to the shell */,
diff --git a/jest.config.js b/jest.config.js
index 878a182..2ceb8e3 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,5 +1,4 @@
 const config = {
-  collectCoverage: true,
   collectCoverageFrom: [
     'lib/**/*.js',
     // Avoid ESM import.meta parse error.
diff --git a/lib/generateTasks.js b/lib/generateTasks.js
index 164d69b..99e3e91 100644
--- a/lib/generateTasks.js
+++ b/lib/generateTasks.js
@@ -21,9 +21,7 @@ export const generateTasks = ({ config, cwd = process.cwd(), files, relative = f
 
   const relativeFiles = files.map((file) => normalize(path.relative(cwd, file)))
 
-  return Object.entries(config).map(([rawPattern, commands]) => {
-    let pattern = rawPattern
-
+  return Object.entries(config).map(([pattern, commands]) => {
     const isParentDirPattern = pattern.startsWith('../')
 
     // Only worry about children of the CWD unless the pattern explicitly
@@ -40,6 +38,7 @@ export const generateTasks = ({ config, cwd = process.cwd(), files, relative = f
       // match against filenames in every directory. This makes `*.js`
       // match both `test.js` and `subdirectory/test.js`.
       matchBase: !pattern.includes('/'),
+      posixSlashes: true,
       strictBrackets: true,
     })
 
diff --git a/lib/getConfigGroups.js b/lib/getConfigGroups.js
deleted file mode 100644
index eff2460..0000000
--- a/lib/getConfigGroups.js
+++ /dev/null
@@ -1,105 +0,0 @@
-/** @typedef {import('./index').Logger} Logger */
-
-import path from 'path'
-
-import debug from 'debug'
-import objectInspect from 'object-inspect'
-
-import { loadConfig } from './loadConfig.js'
-import { ConfigNotFoundError } from './symbols.js'
-import { validateConfig } from './validateConfig.js'
-
-const debugLog = debug('lint-staged:getConfigGroups')
-
-/**
- * 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 {string} [options.files] - List of staged files
- * @param {Logger} logger
- */
-export const getConfigGroups = async (
-  { configObject, configPath, cwd, files },
-  logger = console
-) => {
-  debugLog('Grouping configuration files...')
-
-  // Return explicit config object from js API
-  if (configObject) {
-    debugLog('Using single direct configuration object...')
-
-    const config = validateConfig(configObject, 'config object', logger)
-    return { '': { config, files } }
-  }
-
-  // Use only explicit config path instead of discovering multiple
-  if (configPath) {
-    debugLog('Using single configuration path...')
-
-    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 } }
-  }
-
-  debugLog('Grouping staged files by their directories...')
-
-  // 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
-  }, {})
-
-  debugLog('Grouped staged files into %d directories:', Object.keys(filesByDir).length)
-  debugLog(objectInspect(filesByDir, { indent: 2 }))
-
-  // Group files by their discovered config
-  // { '.lintstagedrc.json': { config: {...}, files: [...] } }
-  const configGroups = {}
-
-  debugLog('Searching config files...')
-
-  const searchConfig = async (cwd, files = []) => {
-    const { config, filepath } = await loadConfig({ cwd }, logger)
-    if (!config) {
-      debugLog('Found no config from "%s"!', cwd)
-      return
-    }
-
-    if (filepath in configGroups) {
-      debugLog('Found existing config "%s" from "%s"!', filepath, cwd)
-      // Re-use cached config and skip validation
-      configGroups[filepath].files.push(...files)
-    } else {
-      debugLog('Found new config "%s" from "%s"!', filepath, cwd)
-
-      const validatedConfig = validateConfig(config, filepath, logger)
-      configGroups[filepath] = { config: validatedConfig, files }
-    }
-  }
-
-  // Start by searching from cwd
-  await searchConfig(cwd)
-
-  // Discover configs from the base directory of each file
-  await Promise.all(Object.entries(filesByDir).map(([dir, files]) => searchConfig(dir, files)))
-
-  debugLog('Grouped staged files into %d groups!', Object.keys(configGroups).length)
-
-  return configGroups
-}
diff --git a/lib/groupFilesByConfig.js b/lib/groupFilesByConfig.js
new file mode 100644
index 0000000..8d734ba
--- /dev/null
+++ b/lib/groupFilesByConfig.js
@@ -0,0 +1,59 @@
+import path from 'path'
+
+import debug from 'debug'
+
+import { ConfigObjectSymbol } from './searchConfigs.js'
+
+const debugLog = debug('lint-staged:groupFilesByConfig')
+
+export const groupFilesByConfig = async ({ configs, files }) => {
+  debugLog('Grouping %d files by %d configurations', files.length, Object.keys(configs).length)
+
+  const filesSet = new Set(files)
+  const filesByConfig = {}
+
+  /** Configs are sorted deepest first by `searchConfigs` */
+  for (const filepath of Reflect.ownKeys(configs)) {
+    const config = configs[filepath]
+
+    /** When passed an explicit config object via the Node.js API, skip logic */
+    if (filepath === ConfigObjectSymbol) {
+      filesByConfig[filepath] = { config, files }
+      break
+    }
+
+    const dir = path.normalize(path.dirname(filepath))
+
+    /** Check if file is inside directory of the configuration file */
+    const isInsideDir = (file) => {
+      const relative = path.relative(dir, file)
+      return relative && !relative.startsWith('..') && !path.isAbsolute(relative)
+    }
+
+    /** This config should match all files since it has a parent glob */
+    const includeAllFiles = Object.keys(config).some((glob) => glob.startsWith('..'))
+
+    const scopedFiles = new Set(includeAllFiles ? filesSet : undefined)
+
+    /**
+     * Without a parent glob, if file is inside the config file's directory,
+     * assign it to that configuration.
+     */
+    if (!includeAllFiles) {
+      filesSet.forEach((file) => {
+        if (isInsideDir(file)) {
+          scopedFiles.add(file)
+        }
+      })
+    }
+
+    /** Files should only match a single config */
+    scopedFiles.forEach((file) => {
+      filesSet.delete(file)
+    })
+
+    filesByConfig[filepath] = { config, files: Array.from(scopedFiles) }
+  }
+
+  return filesByConfig
+}
diff --git a/lib/index.js b/lib/index.js
index 4d2c257..e15616d 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,13 +1,41 @@
 import debug from 'debug'
 
-import { PREVENTED_EMPTY_COMMIT, GIT_ERROR, RESTORE_STASH_EXAMPLE } from './messages.js'
+import {
+  PREVENTED_EMPTY_COMMIT,
+  GIT_ERROR,
+  RESTORE_STASH_EXAMPLE,
+  NO_CONFIGURATION,
+} from './messages.js'
 import { printTaskOutput } from './printTaskOutput.js'
 import { runAll } from './runAll.js'
-import { ApplyEmptyCommitError, GetBackupStashError, GitError } from './symbols.js'
+import {
+  ApplyEmptyCommitError,
+  ConfigNotFoundError,
+  GetBackupStashError,
+  GitError,
+} from './symbols.js'
 import { validateOptions } from './validateOptions.js'
 
 const debugLog = debug('lint-staged')
 
+/**
+ * Get the maximum length of a command-line argument string based on current platform
+ *
+ * https://serverfault.com/questions/69430/what-is-the-maximum-length-of-a-command-line-in-mac-os-x
+ * https://support.microsoft.com/en-us/help/830473/command-prompt-cmd-exe-command-line-string-limitation
+ * https://unix.stackexchange.com/a/120652
+ */
+const getMaxArgLength = () => {
+  switch (process.platform) {
+    case 'darwin':
+      return 262144
+    case 'win32':
+      return 8191
+    default:
+      return 131072
+  }
+}
+
 /**
  * @typedef {(...any) => void} LogFunction
  * @typedef {{ error: LogFunction, log: LogFunction, warn: LogFunction }} Logger
@@ -39,7 +67,7 @@ const lintStaged = async (
     configPath,
     cwd,
     debug = false,
-    maxArgLength,
+    maxArgLength = getMaxArgLength() / 2,
     quiet = false,
     relative = false,
     shell = false,
@@ -78,7 +106,10 @@ const lintStaged = async (
   } catch (runAllError) {
     if (runAllError && runAllError.ctx && runAllError.ctx.errors) {
       const { ctx } = runAllError
-      if (ctx.errors.has(ApplyEmptyCommitError)) {
+
+      if (ctx.errors.has(ConfigNotFoundError)) {
+        logger.error(NO_CONFIGURATION)
+      } else if (ctx.errors.has(ApplyEmptyCommitError)) {
         logger.warn(PREVENTED_EMPTY_COMMIT)
       } else if (ctx.errors.has(GitError) && !ctx.errors.has(GetBackupStashError)) {
         logger.error(GIT_ERROR)
diff --git a/lib/messages.js b/lib/messages.js
index bc8e05b..6083f77 100644
--- a/lib/messages.js
+++ b/lib/messages.js
@@ -22,6 +22,8 @@ export const incorrectBraces = (before, after) =>
 `
   )
 
+export const NO_CONFIGURATION = `${error} No valid configuration found.`
+
 export const NO_STAGED_FILES = `${info} No staged files found.`
 
 export const NO_TASKS = `${info} No staged files match any configured task.`
diff --git a/lib/parseGitZOutput.js b/lib/parseGitZOutput.js
index 931e163..e49e7ce 100644
--- a/lib/parseGitZOutput.js
+++ b/lib/parseGitZOutput.js
@@ -5,5 +5,7 @@
  */
 export const parseGitZOutput = (input) =>
   input
-    .replace(/\u0000$/, '') // eslint-disable-line no-control-regex
-    .split('\u0000')
+    ? input
+        .replace(/\u0000$/, '') // eslint-disable-line no-control-regex
+        .split('\u0000')
+    : []
diff --git a/lib/resolveTaskFn.js b/lib/resolveTaskFn.js
index 6cbf9b8..77a4ab7 100644
--- a/lib/resolveTaskFn.js
+++ b/lib/resolveTaskFn.js
@@ -62,8 +62,10 @@ const interruptExecutionOnError = (ctx, execaChildProcess) => {
     if (ctx.errors.size > 0) {
       clearInterval(loopIntervalId)
 
-      const ids = await pidTree(execaChildProcess.pid)
-      ids.forEach((id) => process.kill(id))
+      const childPids = await pidTree(execaChildProcess.pid)
+      for (const pid of childPids) {
+        process.kill(pid)
+      }
 
       // The execa process is killed separately in order
       // to get the `KILLED` status.
diff --git a/lib/runAll.js b/lib/runAll.js
index c82e1cb..b9c54f7 100644
--- a/lib/runAll.js
+++ b/lib/runAll.js
@@ -10,10 +10,10 @@ 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'
+import { groupFilesByConfig } from './groupFilesByConfig.js'
 import { makeCmdTasks } from './makeCmdTasks.js'
 import {
   DEPRECATED_GIT_ADD,
@@ -36,7 +36,7 @@ import {
   restoreUnstagedChangesSkipped,
 } from './state.js'
 import { GitRepoError, GetStagedFilesError, GitError, ConfigNotFoundError } from './symbols.js'
-import { searchConfigs } from './searchConfigs.js'
+import { ConfigObjectSymbol, searchConfigs } from './searchConfigs.js'
 
 const debugLog = debug('lint-staged:runAll')
 
@@ -120,11 +120,8 @@ export const runAll = async (
     return ctx
   }
 
-  const configGroups = await getConfigGroups({ configObject, configPath, cwd, files }, logger)
-
-  const hasExplicitConfig = configObject || configPath
-  const foundConfigs = hasExplicitConfig ? null : await searchConfigs(gitDir, logger)
-  const numberOfConfigs = hasExplicitConfig ? 1 : Object.keys(foundConfigs).length
+  const foundConfigs = await searchConfigs({ configObject, configPath, cwd, gitDir }, logger)
+  const numberOfConfigs = Reflect.ownKeys(foundConfigs).length
 
   // Throw if no configurations were found
   if (numberOfConfigs === 0) {
@@ -132,7 +129,7 @@ export const runAll = async (
     throw createError(ctx, ConfigNotFoundError)
   }
 
-  debugLog('Found %d configs:\n%O', numberOfConfigs, foundConfigs)
+  const filesByConfig = await groupFilesByConfig({ configs: foundConfigs, files })
 
   const hasMultipleConfigs = numberOfConfigs > 1
 
@@ -152,8 +149,14 @@ 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 [configPath, { config, files }] of Object.entries(configGroups)) {
-    const relativeConfig = normalize(path.relative(cwd, configPath))
+  for (const configPath of Reflect.ownKeys(filesByConfig)) {
+    const { config, files } = filesByConfig[configPath]
+
+    const configName =
+      configPath === ConfigObjectSymbol
+        ? 'Config object'
+        : normalize(path.relative(cwd, configPath))
+
     const stagedFileChunks = chunkFiles({ baseDir: gitDir, files, maxArgLength, relative })
 
     // Use actual cwd if it's specified, or there's only a single config file.
@@ -219,7 +222,7 @@ export const runAll = async (
 
       listrTasks.push({
         title:
-          `${relativeConfig}${dim(` — ${files.length} ${files.length > 1 ? 'files' : 'file'}`)}` +
+          `${configName}${dim(` — ${files.length} ${files.length > 1 ? 'files' : 'file'}`)}` +
           (chunkCount > 1 ? dim(` (chunk ${index + 1}/${chunkCount})...`) : ''),
         task: () => new Listr(chunkListrTasks, { ...listrOptions, concurrent, exitOnError: true }),
         skip: () => {
@@ -227,7 +230,7 @@ export const runAll = async (
           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 `${configName}${dim(' — no tasks to run')}`
           }
           return false
         },
diff --git a/lib/searchConfigs.js b/lib/searchConfigs.js
index bfca1c4..ada2e42 100644
--- a/lib/searchConfigs.js
+++ b/lib/searchConfigs.js
@@ -2,6 +2,7 @@
 
 import { basename, join } from 'path'
 
+import debug from 'debug'
 import normalize from 'normalize-path'
 
 import { execGit } from './execGit.js'
@@ -9,6 +10,8 @@ import { loadConfig, searchPlaces } from './loadConfig.js'
 import { parseGitZOutput } from './parseGitZOutput.js'
 import { validateConfig } from './validateConfig.js'
 
+const debugLog = debug('lint-staged:searchConfigs')
+
 const EXEC_GIT = ['ls-files', '-z', '--full-name']
 
 const filterPossibleConfigFiles = (file) => searchPlaces.includes(basename(file))
@@ -17,14 +20,44 @@ const numberOfLevels = (file) => file.split('/').length
 
 const sortDeepestParth = (a, b) => (numberOfLevels(a) > numberOfLevels(b) ? -1 : 1)
 
+const isInsideDirectory = (dir) => (file) => file.startsWith(normalize(dir))
+
+export const ConfigObjectSymbol = Symbol()
+
 /**
- * Search all config files from the git repository
+ * Search all config files from the git repository, preferring those inside `cwd`.
  *
- * @param {string} gitDir
+ * @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
- * @returns {Promise<{ [key: string]: * }>} found configs with filepath as key, and config as value
+ *
+ * @returns {Promise<{ [key: string]: { config: *, files: string[] } }>} found configs with filepath as key, and config as value
  */
-export const searchConfigs = async (gitDir = process.cwd(), logger) => {
+export const searchConfigs = async (
+  { configObject, configPath, cwd = process.cwd(), gitDir = cwd },
+  logger
+) => {
+  debugLog('Searching for configuration files...')
+
+  // Return explicit config object from js API
+  if (configObject) {
+    debugLog('Using single direct configuration object...')
+
+    return { [ConfigObjectSymbol]: validateConfig(configObject, 'config object', logger) }
+  }
+
+  // Use only explicit config path instead of discovering multiple
+  if (configPath) {
+    debugLog('Using single configuration path...')
+
+    const { config, filepath } = await loadConfig({ configPath }, logger)
+
+    if (!config) return {}
+    return { [configPath]: validateConfig(config, filepath, logger) }
+  }
+
   /** Get all possible config files known to git */
   const cachedFiles = parseGitZOutput(await execGit(EXEC_GIT, { cwd: gitDir })).filter(
     filterPossibleConfigFiles
@@ -39,8 +72,11 @@ export const searchConfigs = async (gitDir = process.cwd(), logger) => {
   const possibleConfigFiles = [...cachedFiles, ...otherFiles]
     .map((file) => join(gitDir, file))
     .map((file) => normalize(file))
+    .filter(isInsideDirectory(cwd))
     .sort(sortDeepestParth)
 
+  debugLog('Found possible config files:', possibleConfigFiles)
+
   /** Create object with key as config file, and value as null */
   const configs = possibleConfigFiles.reduce(
     (acc, configPath) => Object.assign(acc, { [configPath]: null }),
@@ -65,5 +101,24 @@ export const searchConfigs = async (gitDir = process.cwd(), logger) => {
     .filter(([, value]) => !!value)
     .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
 
+  /**
+   * Try to find a single config from parent directories
+   * to match old behavior before monorepo support
+   */
+  if (!Object.keys(foundConfigs).length) {
+    debugLog('Could not find config files inside "%s"', cwd)
+
+    const { config, filepath } = await loadConfig({ cwd }, logger)
+    if (config) {
+      debugLog('Found parent configuration file from "%s"', filepath)
+
+      foundConfigs[filepath] = validateConfig(config, filepath, logger)
+    } else {
+      debugLog('Could not find parent configuration files from "%s"', cwd)
+    }
+  }
+
+  debugLog('Found %d config files', Object.keys(foundConfigs).length)
+
   return foundConfigs
 }


Test patch:
diff --git a/test/getConfigGroups.spec.js b/test/getConfigGroups.spec.js
deleted file mode 100644
index 70ada8e..0000000
--- a/test/getConfigGroups.spec.js
+++ /dev/null
@@ -1,69 +0,0 @@
-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 find config files for all staged files', async () => {
-    // Base cwd
-    loadConfig.mockResolvedValueOnce({ config, filepath: '/.lintstagedrc.json' })
-    // '/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'] },
-    })
-  })
-
-  it('should find config for one file, and not care about other', async () => {
-    // Base cwd
-    loadConfig.mockResolvedValueOnce({})
-    // '/foo.js'
-    loadConfig.mockResolvedValueOnce({})
-    // '/deeper/foo.js'
-    loadConfig.mockResolvedValueOnce({ config, filepath: '/deeper/.lintstagedrc.json' })
-
-    const configGroups = await getConfigGroups({
-      files: ['/foo.js', '/deeper/foo.js'],
-    })
-
-    expect(configGroups).toEqual({
-      '/deeper/.lintstagedrc.json': { config, files: ['/deeper/foo.js'] },
-    })
-  })
-})
diff --git a/test/index2.spec.js b/test/index2.spec.js
index 94cb471..383f8bb 100644
--- a/test/index2.spec.js
+++ b/test/index2.spec.js
@@ -3,7 +3,17 @@ import path from 'path'
 import { Listr } from 'listr2'
 import makeConsoleMock from 'consolemock'
 
+import lintStaged from '../lib/index'
+
 jest.mock('listr2')
+
+const MOCK_CONFIG_FILE = path.join(__dirname, '__mocks__', 'my-config.json')
+const MOCK_STAGED_FILE = path.resolve(__dirname, '__mocks__', 'sample.js')
+
+jest.mock('../lib/getStagedFiles', () => ({
+  getStagedFiles: async () => [MOCK_STAGED_FILE],
+}))
+
 jest.mock('../lib/resolveConfig', () => ({
   /** Unfortunately necessary due to non-ESM tests. */
   resolveConfig: (configPath) => {
@@ -15,12 +25,9 @@ jest.mock('../lib/resolveConfig', () => ({
   },
 }))
 
-jest.mock('../lib/resolveGitRepo')
-
-import lintStaged from '../lib/index'
-import { resolveGitRepo } from '../lib/resolveGitRepo'
-
-resolveGitRepo.mockImplementation(async () => ({ gitDir: 'foo', gitConfigDir: 'bar' }))
+jest.mock('../lib/resolveGitRepo', () => ({
+  resolveGitRepo: async () => ({ gitDir: 'foo', gitConfigDir: 'bar' }),
+}))
 
 describe('lintStaged', () => {
   afterEach(() => {
@@ -29,10 +36,9 @@ describe('lintStaged', () => {
 
   it('should pass quiet flag to Listr', async () => {
     expect.assertions(1)
-    await lintStaged(
-      { configPath: path.join(__dirname, '__mocks__', 'my-config.json'), quiet: true },
-      makeConsoleMock()
-    )
+
+    await lintStaged({ configPath: MOCK_CONFIG_FILE, quiet: true }, makeConsoleMock())
+
     expect(Listr.mock.calls[0][1]).toMatchInlineSnapshot(`
       Object {
         "ctx": Object {
@@ -54,11 +60,12 @@ describe('lintStaged', () => {
     expect.assertions(1)
     await lintStaged(
       {
-        configPath: path.join(__dirname, '__mocks__', 'my-config.json'),
+        configPath: MOCK_CONFIG_FILE,
         debug: true,
       },
       makeConsoleMock()
     )
+
     expect(Listr.mock.calls[0][1]).toMatchInlineSnapshot(`
       Object {
         "ctx": Object {
diff --git a/test/index3.spec.js b/test/index3.spec.js
new file mode 100644
index 0000000..3669779
--- /dev/null
+++ b/test/index3.spec.js
@@ -0,0 +1,142 @@
+import makeConsoleMock from 'consolemock'
+
+import lintStaged from '../lib/index'
+import { runAll } from '../lib/runAll'
+import { getInitialState } from '../lib/state'
+import { ApplyEmptyCommitError, ConfigNotFoundError, GitError } from '../lib/symbols'
+
+jest.mock('../lib/validateOptions.js', () => ({
+  validateOptions: jest.fn(async () => {}),
+}))
+
+jest.mock('../lib/runAll.js', () => ({
+  runAll: jest.fn(async () => {}),
+}))
+
+describe('lintStaged', () => {
+  it('should log error when configuration not found', async () => {
+    const ctx = getInitialState()
+    ctx.errors.add(ConfigNotFoundError)
+    runAll.mockImplementationOnce(async () => {
+      throw { ctx }
+    })
+
+    const logger = makeConsoleMock()
+
+    await expect(lintStaged({}, logger)).resolves.toEqual(false)
+
+    expect(logger.printHistory()).toMatchInlineSnapshot(`
+      "
+      ERROR ✖ No valid configuration found."
+    `)
+  })
+
+  it('should log error when preventing empty commit', async () => {
+    const ctx = getInitialState()
+    ctx.errors.add(ApplyEmptyCommitError)
+    runAll.mockImplementationOnce(async () => {
+      throw { ctx }
+    })
+
+    const logger = makeConsoleMock()
+
+    await expect(lintStaged({}, logger)).resolves.toEqual(false)
+
+    expect(logger.printHistory()).toMatchInlineSnapshot(`
+      "
+      WARN 
+        ⚠ lint-staged prevented an empty git commit.
+        Use the --allow-empty option to continue, or check your task configuration
+      "
+    `)
+  })
+
+  it('should log error when preventing empty commit', async () => {
+    const ctx = getInitialState()
+    ctx.errors.add(ApplyEmptyCommitError)
+    runAll.mockImplementationOnce(async () => {
+      throw { ctx }
+    })
+
+    const logger = makeConsoleMock()
+
+    await expect(lintStaged({}, logger)).resolves.toEqual(false)
+
+    expect(logger.printHistory()).toMatchInlineSnapshot(`
+      "
+      WARN 
+        ⚠ lint-staged prevented an empty git commit.
+        Use the --allow-empty option to continue, or check your task configuration
+      "
+    `)
+  })
+
+  it('should log error when a git operation failed', async () => {
+    const ctx = getInitialState()
+    ctx.shouldBackup = true
+    ctx.errors.add(GitError)
+    runAll.mockImplementationOnce(async () => {
+      throw { ctx }
+    })
+
+    const logger = makeConsoleMock()
+
+    await expect(lintStaged({}, logger)).resolves.toEqual(false)
+
+    expect(logger.printHistory()).toMatchInlineSnapshot(`
+      "
+      ERROR 
+        ✖ lint-staged failed due to a git error.
+      ERROR   Any lost modifications can be restored from a git stash:
+
+          > git stash list
+          stash@{0}: automatic lint-staged backup
+          > git stash apply --index stash@{0}
+      "
+    `)
+  })
+
+  it('should throw when context is malformed', async () => {
+    expect.assertions(2)
+
+    const testError = Symbol()
+
+    runAll.mockImplementationOnce(async () => {
+      throw testError
+    })
+
+    const logger = makeConsoleMock()
+
+    await lintStaged({}, logger).catch((error) => {
+      expect(error).toEqual(testError)
+    })
+
+    expect(logger.printHistory()).toMatchInlineSnapshot(`""`)
+  })
+
+  it.each`
+    platform    | maxArgLength
+    ${'darwin'} | ${262144 / 2}
+    ${'win32'}  | ${8191 / 2}
+    ${'others'} | ${131072 / 2}
+  `(
+    'should use default max arg length of $maxArgLength on $platform',
+    async ({ platform, maxArgLength }) => {
+      const realPlatform = process.platform
+      Object.defineProperty(process, 'platform', {
+        value: platform,
+      })
+
+      await lintStaged({}, makeConsoleMock())
+
+      expect(runAll).toHaveBeenLastCalledWith(
+        expect.objectContaining({ maxArgLength }),
+        expect.objectContaining({})
+      )
+
+      Object.defineProperty(process, 'platform', {
+        value: realPlatform,
+      })
+    }
+  )
+})
diff --git a/test/integration.test.js b/test/integration.test.js
index 68ab708..8d427a0 100644
--- a/test/integration.test.js
+++ b/test/integration.test.js
@@ -298,12 +298,12 @@ describe('lint-staged', () => {
       LOG [STARTED] Hiding unstaged changes to partially staged files...
       LOG [SUCCESS] Hiding unstaged changes to partially staged files...
       LOG [STARTED] Running tasks for staged files...
-      LOG [STARTED] <path>/lint-staged — 1 file
+      LOG [STARTED] Config object — 1 file
       LOG [STARTED] *.js — 1 file
       LOG [STARTED] prettier --list-different
       LOG [SUCCESS] prettier --list-different
       LOG [SUCCESS] *.js — 1 file
-      LOG [SUCCESS] <path>/lint-staged — 1 file
+      LOG [SUCCESS] Config object — 1 file
       LOG [SUCCESS] Running tasks for staged files...
       LOG [STARTED] Applying modifications from tasks...
       LOG [SUCCESS] Applying modifications from tasks...
@@ -500,6 +500,8 @@ describe('lint-staged', () => {
   })
 
   it('should handle merge conflicts', async () => {
+    await execGit(['config', 'merge.conflictstyle', 'merge'])
+
     const fileInBranchA = `module.exports = "foo";\n`
     const fileInBranchB = `module.exports = 'bar'\n`
     const fileInBranchBFixed = `module.exports = "bar";\n`
@@ -561,6 +563,8 @@ describe('lint-staged', () => {
   })
 
   it('should handle merge conflict when task errors', async () => {
+    await execGit(['config', 'merge.conflictstyle', 'merge'])
+
     const fileInBranchA = `module.exports = "foo";\n`
     const fileInBranchB = `module.exports = 'bar'\n`
     const fileInBranchBFixed = `module.exports = "bar";\n`
@@ -792,12 +796,12 @@ describe('lint-staged', () => {
       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] Config object — 1 file
       LOG [STARTED] *.js — 1 file
       LOG [STARTED] git stash drop
       LOG [SUCCESS] git stash drop
       LOG [SUCCESS] *.js — 1 file
-      LOG [SUCCESS] <path>/lint-staged — 1 file
+      LOG [SUCCESS] Config object — 1 file
       LOG [SUCCESS] Running tasks for staged files...
       LOG [STARTED] Applying modifications from tasks...
       LOG [SUCCESS] Applying modifications from tasks...
@@ -832,14 +836,14 @@ describe('lint-staged', () => {
       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] Config object — 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] *.js — 1 file
-      LOG [SUCCESS] <path>/lint-staged — 1 file
+      LOG [SUCCESS] Config object — 1 file
       LOG [SUCCESS] Running tasks for staged files...
       LOG [STARTED] Applying modifications from tasks...
       ERROR [FAILED] Prevented an empty git commit!
@@ -982,12 +986,12 @@ describe('lint-staged', () => {
       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] Config object — 1 file
       LOG [STARTED] *.js — 1 file
       LOG [STARTED] prettier --write
       LOG [SUCCESS] prettier --write
       LOG [SUCCESS] *.js — 1 file
-      LOG [SUCCESS] <path>/lint-staged — 1 file
+      LOG [SUCCESS] Config object — 1 file
       LOG [SUCCESS] Running tasks for staged files...
       LOG [STARTED] Applying modifications from tasks...
       LOG [SUCCESS] Applying modifications from tasks..."
@@ -1000,6 +1004,8 @@ describe('lint-staged', () => {
   })
 
   it('should abort commit without reverting with --no-stash 1', async () => {
+    await execGit(['config', 'merge.conflictstyle', 'merge'])
+
     // Stage file
     await appendFile('test.js', testJsFileUgly)
     await execGit(['add', 'test.js'])
@@ -1027,12 +1033,12 @@ describe('lint-staged', () => {
       LOG [STARTED] Hiding unstaged changes to partially staged files...
       LOG [SUCCESS] Hiding unstaged changes to partially staged files...
       LOG [STARTED] Running tasks for staged files...
-      LOG [STARTED] <path>/lint-staged — 1 file
+      LOG [STARTED] Config object — 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] Config object — 1 file
       LOG [SUCCESS] Running tasks for staged files...
       LOG [STARTED] Applying modifications from tasks...
       LOG [SUCCESS] Applying modifications from tasks...
@@ -1188,6 +1194,77 @@ describe('lint-staged', () => {
     )
   })
 
+  it('should ignore multiple configs files outside cwd', 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
+    // Run in 'deeper/' so that root config is ignored
+    await gitCommit({ shell: true, cwd: path.join(cwd, 'deeper') })
+
+    // 'file.js' was ignored
+    expect(await readFile('file.js')).toEqual('')
+
+    // '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' was ignored
+    expect(await readFile('a/very/deep/file/path/file.js')).toEqual('')
+  })
+
+  it('should support parent globs', 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', '')
+
+    // Include single-level parent glob in deeper config
+    await writeFile(
+      'deeper/even/.lintstagedrc.cjs',
+      `module.exports = { '../*.js': (files) => files.map((f) => \`echo level-2 > \${f}\`) }`
+    )
+
+    // Stage all files
+    await execGit(['add', '.'])
+
+    // Run lint-staged with `--shell` so that tasks do their thing
+    // Run in 'deeper/' so that root config is ignored
+    await gitCommit({ shell: true, cwd: path.join(cwd, 'deeper/even') })
+
+    // Two levels above, no match
+    expect(await readFile('file.js')).toEqual('')
+
+    // One level above, match
+    expect(await readFile('deeper/file.js')).toMatch('level-2')
+
+    // Not directly in the above-level, no match
+    expect(await readFile('deeper/even/file.js')).toEqual('')
+    expect(await readFile('deeper/even/deeper/file.js')).toEqual('')
+    expect(await readFile('a/very/deep/file/path/file.js')).toEqual('')
+  })
+
   it('should not care about staged file outside current cwd with another staged file', async () => {
     await writeFile('file.js', testJsFileUgly)
     await writeFile('deeper/file.js', testJsFileUgly)
@@ -1231,8 +1308,8 @@ describe('lintStaged', () => {
     await appendFile('test.js', testJsFilePretty, cwd)
     await execGit(['add', 'test.js'], { cwd })
 
-    await expect(execGit(['log', '-1'], { cwd })).rejects.toThrowErrorMatchingInlineSnapshot(
-      `"fatal: your current branch '${defaultBranchName}' does not have any commits yet"`
+    await expect(execGit(['log', '-1'], { cwd })).rejects.toThrowError(
+      'does not have any commits yet'
     )
 
     await gitCommit({
@@ -1248,12 +1325,12 @@ describe('lintStaged', () => {
       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] Config object — 1 file
       LOG [STARTED] *.js — 1 file
       LOG [STARTED] prettier --list-different
       LOG [SUCCESS] prettier --list-different
       LOG [SUCCESS] *.js — 1 file
-      LOG [SUCCESS] <path>/lint-staged — 1 file
+      LOG [SUCCESS] Config object — 1 file
       LOG [SUCCESS] Running tasks for staged files...
       LOG [STARTED] Applying modifications from tasks...
       LOG [SUCCESS] Applying modifications from tasks..."
diff --git a/test/parseGitZOutput.spec.js b/test/parseGitZOutput.spec.js
index 772921a..6a24a35 100644
--- a/test/parseGitZOutput.spec.js
+++ b/test/parseGitZOutput.spec.js
@@ -10,4 +10,9 @@ describe('parseGitZOutput', () => {
     const input = 'a\u0000'
     expect(parseGitZOutput(input)).toEqual(['a'])
   })
+
+  it('should handle empty input', () => {
+    const input = ''
+    expect(parseGitZOutput(input)).toEqual([])
+  })
 })
diff --git a/test/resolveTaskFn.spec.js b/test/resolveTaskFn.spec.js
index 0f3cc29..f46d579 100644
--- a/test/resolveTaskFn.spec.js
+++ b/test/resolveTaskFn.spec.js
@@ -1,4 +1,5 @@
 import execa from 'execa'
+import pidTree from 'pidtree'
 
 import { resolveTaskFn } from '../lib/resolveTaskFn'
 import { getInitialState } from '../lib/state'
@@ -6,9 +7,13 @@ import { TaskError } from '../lib/symbols'
 
 import { createExecaReturnValue } from './utils/createExecaReturnValue'
 
+jest.useFakeTimers()
+
+jest.mock('pidtree', () => jest.fn(async () => []))
+
 const defaultOpts = { files: ['test.js'] }
 
-function mockExecaImplementationOnce(value) {
+const mockExecaImplementationOnce = (value) => {
   execa.mockImplementationOnce(() => createExecaReturnValue(value))
 }
 
@@ -350,6 +355,52 @@ describe('resolveTaskFn', () => {
 
     context.errors.add({})
 
+    jest.runAllTimers()
+
     await expect(taskPromise).rejects.toThrowErrorMatchingInlineSnapshot(`"node [KILLED]"`)
   })
+
+  it('should also kill child processes of killed execa processes', async () => {
+    expect.assertions(3)
+
+    execa.mockImplementationOnce(() =>
+      createExecaReturnValue(
+        {
+          stdout: 'a-ok',
+          stderr: '',
+          code: 0,
+          cmd: 'mock cmd',
+          failed: false,
+          killed: false,
+          signal: null,
+        },
+        1000
+      )
+    )
+
+    const realKill = process.kill
+    const mockKill = jest.fn()
+    Object.defineProperty(process, 'kill', {
+      value: mockKill,
+    })
+
+    pidTree.mockImplementationOnce(() => ['1234'])
+
+    const taskFn = resolveTaskFn({ command: 'node' })
+
+    const context = getInitialState()
+    const taskPromise = taskFn(context)
+
+    context.errors.add({})
+    jest.runAllTimers()
+
+    await expect(taskPromise).rejects.toThrowErrorMatchingInlineSnapshot(`"node [KILLED]"`)
+
+    expect(mockKill).toHaveBeenCalledTimes(1)
+    expect(mockKill).toHaveBeenCalledWith('1234')
+
+    Object.defineProperty(process, 'kill', {
+      value: realKill,
+    })
+  })
 })
diff --git a/test/runAll.spec.js b/test/runAll.spec.js
index 6865939..f890383 100644
--- a/test/runAll.spec.js
+++ b/test/runAll.spec.js
@@ -10,7 +10,6 @@ import { resolveGitRepo } from '../lib/resolveGitRepo'
 import { runAll } from '../lib/runAll'
 import { ConfigNotFoundError, GitError } from '../lib/symbols'
 import * as searchConfigsNS from '../lib/searchConfigs'
-import * as getConfigGroupsNS from '../lib/getConfigGroups'
 
 import { createExecaReturnValue } from './utils/createExecaReturnValue'
 
@@ -31,7 +30,6 @@ jest.mock('../lib/resolveConfig', () => ({
 }))
 
 const searchConfigs = jest.spyOn(searchConfigsNS, 'searchConfigs')
-const getConfigGroups = jest.spyOn(getConfigGroupsNS, 'getConfigGroups')
 
 getStagedFiles.mockImplementation(async () => [])
 
@@ -146,12 +144,12 @@ describe('runAll', () => {
       LOG [STARTED] Preparing lint-staged...
       LOG [SUCCESS] Preparing lint-staged...
       LOG [STARTED] Running tasks for staged files...
-      LOG [STARTED]  — 1 file
+      LOG [STARTED] Config object — 1 file
       LOG [STARTED] *.js — 1 file
       LOG [STARTED] echo \\"sample\\"
       LOG [SUCCESS] echo \\"sample\\"
       LOG [SUCCESS] *.js — 1 file
-      LOG [SUCCESS]  — 1 file
+      LOG [SUCCESS] Config object — 1 file
       LOG [SUCCESS] Running tasks for staged files...
       LOG [STARTED] Applying modifications from tasks...
       LOG [SUCCESS] Applying modifications from tasks...
@@ -212,7 +210,7 @@ describe('runAll', () => {
       LOG [STARTED] Preparing lint-staged...
       LOG [SUCCESS] Preparing lint-staged...
       LOG [STARTED] Running tasks for staged files...
-      LOG [STARTED]  — 1 file
+      LOG [STARTED] Config object — 1 file
       LOG [STARTED] *.js — 1 file
       LOG [STARTED] echo \\"sample\\"
       ERROR [FAILED] echo \\"sample\\" [1]
@@ -251,7 +249,7 @@ describe('runAll', () => {
       LOG [STARTED] Preparing lint-staged...
       LOG [SUCCESS] Preparing lint-staged...
       LOG [STARTED] Running tasks for staged files...
-      LOG [STARTED]  — 1 file
+      LOG [STARTED] Config object — 1 file
       LOG [STARTED] *.js — 1 file
       LOG [STARTED] echo \\"sample\\"
       ERROR [FAILED] echo \\"sample\\" [KILLED]
@@ -307,20 +305,9 @@ describe('runAll', () => {
 
     const mockTask = jest.fn(() => ['echo "sample"'])
 
-    getConfigGroups.mockResolvedValueOnce({
-      '.lintstagedrc.json': {
-        config: { '*.js': mockTask },
-        files: ['foo.js'],
-      },
-      'test/.lintstagedrc.json': {
-        config: { '*.js': mockTask },
-        files: ['test/foo.js'],
-      },
-    })
-
     searchConfigs.mockResolvedValueOnce({
-      '.lintstagedrc.json': { '*.js': mockTask },
       'test/.lintstagedrc.json': { '*.js': mockTask },
+      '.lintstagedrc.json': { '*.js': mockTask },
     })
 
     // We are only interested in the `matchedFileChunks` generation
@@ -328,12 +315,7 @@ describe('runAll', () => {
     const mockConstructor = jest.fn(({ matchedFileChunks }) => (expected = matchedFileChunks))
     GitWorkflow.mockImplementationOnce(mockConstructor)
 
-    await expect(
-      runAll({
-        stash: false,
-        relative: true,
-      })
-    ).rejects.toThrowError()
+    await expect(runAll({ stash: false, relative: true })).rejects.toThrowError()
 
     // task received relative `foo.js` from both directories
     expect(mockTask).toHaveBeenCalledTimes(2)
@@ -343,8 +325,8 @@ describe('runAll', () => {
     expect(mockConstructor).toHaveBeenCalledTimes(1)
     expect(expected).toEqual([
       [
-        normalize(path.join(process.cwd(), 'foo.js')),
         normalize(path.join(process.cwd(), 'test/foo.js')),
+        normalize(path.join(process.cwd(), 'foo.js')),
       ],
     ])
   })
@@ -354,20 +336,9 @@ describe('runAll', () => {
 
     const mockTask = jest.fn(() => ['echo "sample"'])
 
-    getConfigGroups.mockResolvedValueOnce({
-      '.lintstagedrc.json': {
-        config: { '*.js': mockTask },
-        files: ['foo.js'],
-      },
-      'test/.lintstagedrc.json': {
-        config: { '*.js': mockTask },
-        files: ['test/foo.js'],
-      },
-    })
-
     searchConfigs.mockResolvedValueOnce({
-      '.lintstagedrc.json': { '*.js': mockTask },
       'test/.lintstagedrc.json': { '*.js': mockTask },
+      '.lintstagedrc.json': { '*.js': mockTask },
     })
 
     await expect(
@@ -379,16 +350,14 @@ describe('runAll', () => {
     ).rejects.toThrowError()
 
     expect(mockTask).toHaveBeenCalledTimes(2)
-    expect(mockTask).toHaveBeenNthCalledWith(1, ['foo.js'])
     // This is now relative to "." instead of "test/"
-    expect(mockTask).toHaveBeenNthCalledWith(2, ['test/foo.js'])
+    expect(mockTask).toHaveBeenNthCalledWith(1, ['test/foo.js'])
+    expect(mockTask).toHaveBeenNthCalledWith(2, ['foo.js'])
   })
 
   it('should error when no configurations found', async () => {
     getStagedFiles.mockImplementationOnce(async () => ['foo.js', 'test/foo.js'])
 
-    getConfigGroups.mockResolvedValueOnce({})
-
     searchConfigs.mockResolvedValueOnce({})
 
     expect.assertions(1)
diff --git a/test/searchConfigs.spec.js b/test/searchConfigs.spec.js
new file mode 100644
index 0000000..74be8a1
--- /dev/null
+++ b/test/searchConfigs.spec.js
@@ -0,0 +1,118 @@
+import path from 'path'
+
+import normalize from 'normalize-path'
+
+import { execGit } from '../lib/execGit.js'
+import { loadConfig } from '../lib/loadConfig.js'
+import { ConfigObjectSymbol, searchConfigs } from '../lib/searchConfigs.js'
+
+jest.mock('../lib/resolveConfig', () => ({
+  /** Unfortunately necessary due to non-ESM tests. */
+  resolveConfig: (configPath) => {
+    try {
+      return require.resolve(configPath)
+    } catch {
+      return configPath
+    }
+  },
+}))
+
+jest.mock('../lib/execGit.js', () => ({
+  execGit: jest.fn(async () => {
+    /** Mock fails by default */
+    return ''
+  }),
+}))
+
+jest.mock('../lib/loadConfig.js', () => {
+  const { searchPlaces } = jest.requireActual('../lib/loadConfig.js')
+
+  return {
+    searchPlaces,
+    loadConfig: jest.fn(async () => {
+      /** Mock fails by default */
+      return {}
+    }),
+  }
+})
+
+describe('searchConfigs', () => {
+  afterEach(() => {
+    jest.clearAllMocks()
+  })
+
+  it('should throw for invalid config object', async () => {
+    await expect(searchConfigs({ configObject: {} })).rejects.toThrowError()
+  })
+
+  it('should return config for valid config object', async () => {
+    await expect(searchConfigs({ configObject: { '*.js': 'eslint' } })).resolves.toEqual({
+      [ConfigObjectSymbol]: { '*.js': 'eslint' },
+    })
+  })
+
+  it('should return empty object for invalid config path', async () => {
+    await expect(
+      searchConfigs({ configPath: path.join(__dirname, 'missing.json') })
+    ).resolves.toEqual({})
+  })
+
+  it('should return config for valid config path', async () => {
+    const configPath = '.lintstagedrc.json'
+    const config = { '*.js': 'eslint' }
+    loadConfig.mockReturnValueOnce({ config, configPath })
+
+    await expect(searchConfigs({ configPath })).resolves.toEqual({
+      [configPath]: config,
+    })
+  })
+
+  it('should return empty object when no config files found', async () => {
+    await expect(searchConfigs({})).resolves.toEqual({})
+  })
+
+  it('should return empty object when no valid config files found', async () => {
+    execGit.mockResolvedValueOnce(`.lintstagedrc.json\u0000`)
+
+    await expect(searchConfigs({})).resolves.toEqual({})
+  })
+
+  it('should return config found from git', async () => {
+    const configPath = '.lintstagedrc.json'
+    const config = { '*.js': 'eslint' }
+
+    execGit.mockResolvedValueOnce(`${configPath}\u0000`)
+    loadConfig.mockResolvedValueOnce({ config, filepath: configPath })
+
+    await expect(searchConfigs({})).resolves.toEqual({ [configPath]: config })
+  })
+
+  it('should return auto-discovered config from cwd when not found from git', async () => {
+    const configPath = '.lintstagedrc.json'
+    const config = { '*.js': 'eslint' }
+
+    loadConfig.mockResolvedValueOnce({ config, filepath: configPath })
+
+    await expect(searchConfigs({})).resolves.toEqual({ [configPath]: config })
+  })
+
+  it('should sort configs so that deepest is first', async () => {
+    const config = { '*.js': 'eslint' }
+
+    execGit.mockResolvedValueOnce(
+      `.lintstagedrc.json\u0000even/deeper/.lintstagedrc.json\u0000deeper/.lintstagedrc.json\u0000`
+    )
+
+    const topLevelConfig = normalize(path.join(process.cwd(), '.lintstagedrc.json'))
+    const deeperConfig = normalize(path.join(process.cwd(), 'deeper/.lintstagedrc.json'))
+    const evenDeeperConfig = normalize(path.join(process.cwd(), 'even/deeper/.lintstagedrc.json'))
+
+    loadConfig.mockResolvedValueOnce({ config, filepath: topLevelConfig })
+    loadConfig.mockResolvedValueOnce({ config, filepath: deeperConfig })
+    loadConfig.mockResolvedValueOnce({ config, filepath: evenDeeperConfig })
+
+    const configs = await searchConfigs({})
+
+    expect(Object.keys(configs)).toEqual([evenDeeperConfig, deeperConfig, topLevelConfig])
+  })
+})