From 9a14e92e37abf658fc3a0d5504ff4e980e49996c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iiro=20J=C3=A4ppinen?= Date: Tue, 1 Feb 2022 20:01:03 +0200 Subject: [PATCH] fix: use config directory as cwd, when multiple configs present (#1091) --- lib/runAll.js | 26 ++++++++++---- test/integration.test.js | 38 ++++++++++++++++++++ test/runAll.spec.js | 75 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 6 deletions(-) diff --git a/lib/runAll.js b/lib/runAll.js index 4604fdb52..1e282630a 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 = diff --git a/test/integration.test.js b/test/integration.test.js index e0f26b5d2..6629174ae 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 9560638fb..01168ecea 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']) + }) })