Repository: okonet/lint-staged
Pull request #1093 (2022-01-20 16:16:48)

fix: always search config from `cwd` first
This PR makes lint-staged always search for a configuration file from the `cwd`, even if no staged files are inside this. This fixes https://github.com/okonet/lint-staged/issues/1092.

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

> :exclamation: Current head b2c543e differs from pull request most recent head 0074201. Consider uploading reports for the commit 0074201 to get more accurate results
[![Impacted file tree graph](https://codecov.io/gh/okonet/lint-staged/pull/1093/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/1093?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     #1093   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           23        23           
  Lines          624       641   +17     
  Branches       167       169    +2     
=========================================
+ Hits           624       641   +17     
```


| [Impacted Files](https://codecov.io/gh/okonet/lint-staged/pull/1093?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) | Coverage Δ | |
|---|---|---|
| [lib/getConfigGroups.js](https://codecov.io/gh/okonet/lint-staged/pull/1093/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/runAll.js](https://codecov.io/gh/okonet/lint-staged/pull/1093/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/1093?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/1093?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov). Last update [36b9546...0074201](https://codecov.io/gh/okonet/lint-staged/pull/1093?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).


Patch:
diff --git a/lib/getConfigGroups.js b/lib/getConfigGroups.js
index c692b84..db1ce14 100644
--- a/lib/getConfigGroups.js
+++ b/lib/getConfigGroups.js
@@ -2,10 +2,15 @@
 
 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.
  *
@@ -13,17 +18,27 @@ import { validateConfig } from './validateConfig.js'
  * @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, files }, logger = console) => {
+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) {
@@ -35,6 +50,8 @@ export const getConfigGroups = async ({ configObject, configPath, files }, logge
     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))
@@ -48,33 +65,48 @@ export const getConfigGroups = async ({ configObject, configPath, files }, logge
     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 = {}
 
-  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) return
+  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)
 
-        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 }
+    }
+  }
 
-        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)))
 
   // Throw if no configurations were found
   if (Object.keys(configGroups).length === 0) {
+    debugLog('Found no config groups!')
     logger.error(`${ConfigNotFoundError.message}.`)
     throw ConfigNotFoundError
   }
 
+  debugLog('Grouped staged files into %d groups!', Object.keys(configGroups).length)
+
   return configGroups
 }
diff --git a/lib/runAll.js b/lib/runAll.js
index dfe7997..eebce3d 100644
--- a/lib/runAll.js
+++ b/lib/runAll.js
@@ -114,7 +114,7 @@ export const runAll = async (
     return ctx
   }
 
-  const configGroups = await getConfigGroups({ configObject, configPath, files }, logger)
+  const configGroups = await getConfigGroups({ configObject, configPath, cwd, files }, logger)
 
   // lint-staged 10 will automatically add modifications to index
   // Warn user when their command includes `git add`


Test patch:
diff --git a/test/getConfigGroups.spec.js b/test/getConfigGroups.spec.js
index 3607659..6f1cedd 100644
--- a/test/getConfigGroups.spec.js
+++ b/test/getConfigGroups.spec.js
@@ -37,6 +37,8 @@ describe('getConfigGroups', () => {
   })
 
   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'
@@ -55,6 +57,8 @@ describe('getConfigGroups', () => {
   })
 
   it('should find config for one file, and not care about other', async () => {
+    // Base cwd
+    loadConfig.mockResolvedValueOnce({})
     // '/foo.js'
     loadConfig.mockResolvedValueOnce({})
     // '/deeper/foo.js'
diff --git a/test/integration.test.js b/test/integration.test.js
index cefe6e3..e0f26b5 100644
--- a/test/integration.test.js
+++ b/test/integration.test.js
@@ -32,6 +32,7 @@ import { createTempDir } from './utils/tempDir'
 import { isWindowsActions, normalizeWindowsNewlines } from './utils/crossPlatform'
 
 jest.setTimeout(20000)
+jest.retryTimes(2)
 
 // Replace path like `../../git/lint-staged` with `<path>/lint-staged`
 const replaceConfigPathSerializer = replaceSerializer(
@@ -1147,6 +1148,35 @@ describe('lint-staged', () => {
     // 'a/very/deep/file/path/file.js' matched '.lintstagedrc.json'
     expect(await readFile('a/very/deep/file/path/file.js')).toMatch('level-0')
   })
+
+  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)
+    await writeFile('deeper/.lintstagedrc.json', JSON.stringify(fixJsConfig.config))
+    await execGit(['add', '.'])
+
+    // Run lint-staged in "deeper/""
+    expect(await gitCommit({ cwd: path.join(cwd, 'deeper') })).resolves
+
+    // File inside deeper/ was fixed
+    expect(await readFile('deeper/file.js')).toEqual(testJsFilePretty)
+    // ...but file outside was not
+    expect(await readFile('file.js')).toEqual(testJsFileUgly)
+  })
+
+  it('should not care about staged file outside current cwd without any other staged files', async () => {
+    await writeFile('file.js', testJsFileUgly)
+    await writeFile('deeper/.lintstagedrc.json', JSON.stringify(fixJsConfig.config))
+    await execGit(['add', '.'])
+
+    // Run lint-staged in "deeper/""
+    expect(await gitCommit({ cwd: path.join(cwd, 'deeper') })).resolves
+
+    expect(console.printHistory()).toMatch('No staged files match any configured task')
+
+    // File outside deeper/ was not fixed
+    expect(await readFile('file.js')).toEqual(testJsFileUgly)
+  })
 })
 
 describe('lintStaged', () => {