Repository: okonet/lint-staged
Pull request #1091 (2022-01-19 17:36:33)

fix: use config directory as cwd, when multiple configs present
This PR makes tasks run in the directory of the config file, when there are multiple configuration files.

In the case of only a single configuration file, tasks will still run in the current working directory, so this shouldn't really be a breaking change.

I also added some debug logging to `getConfigGroups`.

What do you think, @okonet?

iiroj: I guess the only question is that should there be an info message about this...
codecov[bot]: # [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/1091?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) Report
> Merging [#1091](https://codecov.io/gh/okonet/lint-staged/pull/1091?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov) (4bc7685) 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 4bc7685 differs from pull request most recent head 0f68ed1. Consider uploading reports for the commit 0f68ed1 to get more accurate results
[![Impacted file tree graph](https://codecov.io/gh/okonet/lint-staged/pull/1091/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/1091?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     #1091   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           23        23           
  Lines          624       643   +19     
  Branches       167       171    +4     
=========================================
+ Hits           624       643   +19     
```


| [Impacted Files](https://codecov.io/gh/okonet/lint-staged/pull/1091?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/1091/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/1091/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/1091?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/1091?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Andrey+Okonetchnikov). Last update [36b9546...0f68ed1](https://codecov.io/gh/okonet/lint-staged/pull/1091?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: Maybe something like:

```
i Lint-staged detected multiple config files.
  Tasks will run independently from each directory.
```
iiroj: I rebased this to https://github.com/okonet/lint-staged/pull/1094
rbayliss: Just wanted to note that I stumbled across this issue after realizing that my task binaries weren't being resolved as expected for subdirectory configurations. In our case, we've got a root configuration that covers several directories, then a subdirectory configuration that covers that one specific subdirectory.

After this change, tasks from both the subdirectory and root directory are behaving as I'd expect.
iiroj: What do you think @okonet? Should we merge this and then investigate on how to merge/run nested configurations?

Patch:
diff --git a/lib/runAll.js b/lib/runAll.js
index 4604fdb..1e28263 100644
--- a/lib/runAll.js
+++ b/lib/runAll.js
@@ -80,7 +80,8 @@ export const runAll = async (
   debugLog('Running all linter scripts...')
 
   // Resolve relative CWD option
-  cwd = cwd ? path.resolve(cwd) : process.cwd()
+  const hasExplicitCwd = !!cwd
+  cwd = hasExplicitCwd ? path.resolve(cwd) : process.cwd()
   debugLog('Using working directory `%s`', cwd)
 
   const ctx = getInitialState({ quiet })
@@ -120,6 +121,8 @@ export const runAll = async (
 
   const configGroups = await getConfigGroups({ configObject, configPath, cwd, files }, logger)
 
+  const hasMultipleConfigs = Object.keys(configGroups).length > 1
+
   // lint-staged 10 will automatically add modifications to index
   // Warn user when their command includes `git add`
   let hasDeprecatedGitAdd = false
@@ -138,21 +141,25 @@ export const runAll = async (
   const matchedFiles = new Set()
 
   for (const [configPath, { config, files }] of Object.entries(configGroups)) {
+    const relativeConfig = 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.
+    // Otherwise use the directory of the config file for each config group,
+    // to make sure tasks are separated from each other.
+    const groupCwd = hasMultipleConfigs && !hasExplicitCwd ? path.dirname(configPath) : cwd
+
     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) =>
+        generateTasks({ config, cwd: groupCwd, files, relative }).map((task) =>
           makeCmdTasks({
             commands: task.commands,
-            cwd,
+            cwd: groupCwd,
             files: task.fileList,
             gitDir,
             renderer: listrOptions.renderer,
@@ -161,7 +168,14 @@ export const runAll = async (
           }).then((subTasks) => {
             // Add files from task to match set
             task.fileList.forEach((file) => {
-              matchedFiles.add(file)
+              // Make sure relative files are normalized to the
+              // group cwd, because other there might be identical
+              // relative filenames in the entire set.
+              const normalizedFile = path.isAbsolute(file)
+                ? file
+                : normalize(path.join(groupCwd, file))
+
+              matchedFiles.add(normalizedFile)
             })
 
             hasDeprecatedGitAdd =


Test patch:
diff --git a/test/integration.test.js b/test/integration.test.js
index e0f26b5..6629174 100644
--- a/test/integration.test.js
+++ b/test/integration.test.js
@@ -1149,6 +1149,44 @@ describe('lint-staged', () => {
     expect(await readFile('a/very/deep/file/path/file.js')).toMatch('level-0')
   })
 
+  it('should support multiple configuration files with --relative', 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 = `module.exports = { '*.js': (files) => files.map((f) => \`echo \${f} > \${f}\`) }`
+
+    await writeFile('.lintstagedrc.js', echoJSConfig)
+    await writeFile('deeper/.lintstagedrc.js', echoJSConfig)
+    await writeFile('deeper/even/.lintstagedrc.cjs', echoJSConfig)
+
+    // Stage all files
+    await execGit(['add', '.'])
+
+    // Run lint-staged with `--shell` so that tasks do their thing
+    await gitCommit({ relative: true, shell: true })
+
+    // 'file.js' is relative to '.'
+    expect(await readFile('file.js')).toMatch('file.js')
+
+    // 'deeper/file.js' is relative to 'deeper/'
+    expect(await readFile('deeper/file.js')).toMatch('file.js')
+
+    // 'deeper/even/file.js' is relative to 'deeper/even/'
+    expect(await readFile('deeper/even/file.js')).toMatch('file.js')
+
+    // 'deeper/even/deeper/file.js' is relative to parent 'deeper/even/'
+    expect(await readFile('deeper/even/deeper/file.js')).toMatch(normalize('deeper/file.js'))
+
+    // 'a/very/deep/file/path/file.js' is relative to root '.'
+    expect(await readFile('a/very/deep/file/path/file.js')).toMatch(
+      normalize('a/very/deep/file/path/file.js')
+    )
+  })
+
   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)
diff --git a/test/runAll.spec.js b/test/runAll.spec.js
index 9560638..01168ec 100644
--- a/test/runAll.spec.js
+++ b/test/runAll.spec.js
@@ -9,6 +9,7 @@ import { GitWorkflow } from '../lib/gitWorkflow'
 import { resolveGitRepo } from '../lib/resolveGitRepo'
 import { runAll } from '../lib/runAll'
 import { GitError } from '../lib/symbols'
+import * as getConfigGroupsNS from '../lib/getConfigGroups'
 
 jest.mock('../lib/file')
 jest.mock('../lib/getStagedFiles')
@@ -26,6 +27,8 @@ jest.mock('../lib/resolveConfig', () => ({
   },
 }))
 
+const getConfigGroups = jest.spyOn(getConfigGroupsNS, 'getConfigGroups')
+
 getStagedFiles.mockImplementation(async () => [])
 
 resolveGitRepo.mockImplementation(async () => {
@@ -293,4 +296,76 @@ describe('runAll', () => {
     expect(mockConstructor).toHaveBeenCalledTimes(1)
     expect(expected).toEqual([[normalize(path.join(cwd, 'test/foo.js'))]])
   })
+
+  it('should resolve matched files to config locations with multiple configs', async () => {
+    getStagedFiles.mockImplementationOnce(async () => ['foo.js', 'test/foo.js'])
+
+    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'],
+      },
+    })
+
+    // We are only interested in the `matchedFileChunks` generation
+    let expected
+    const mockConstructor = jest.fn(({ matchedFileChunks }) => (expected = matchedFileChunks))
+    GitWorkflow.mockImplementationOnce(mockConstructor)
+
+    try {
+      await runAll({
+        stash: false,
+        relative: true,
+      })
+    } catch {} // eslint-disable-line no-empty
+
+    // task received relative `foo.js` from both directories
+    expect(mockTask).toHaveBeenCalledTimes(2)
+    expect(mockTask).toHaveBeenNthCalledWith(1, ['foo.js'])
+    expect(mockTask).toHaveBeenNthCalledWith(2, ['foo.js'])
+    // GitWorkflow received absolute paths `foo.js` and `test/foo.js`
+    expect(mockConstructor).toHaveBeenCalledTimes(1)
+    expect(expected).toEqual([
+      [
+        normalize(path.join(process.cwd(), 'foo.js')),
+        normalize(path.join(process.cwd(), 'test/foo.js')),
+      ],
+    ])
+  })
+
+  it('should resolve matched files to explicit cwd with multiple configs', async () => {
+    getStagedFiles.mockImplementationOnce(async () => ['foo.js', 'test/foo.js'])
+
+    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'],
+      },
+    })
+
+    try {
+      await runAll({
+        cwd: '.',
+        stash: false,
+        relative: true,
+      })
+    } catch {} // eslint-disable-line no-empty
+
+    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'])
+  })
 })