diff --git a/lib/getConfigGroups.js b/lib/getConfigGroups.js deleted file mode 100644 index eff2460eb..000000000 --- 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 000000000..a63836ab7 --- /dev/null +++ b/lib/groupFilesByConfig.js @@ -0,0 +1,53 @@ +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) + } + + const scopedFiles = new Set() + + /** + * If file is inside the config file's directory, assign it to that configuration + * and remove it from the set. This means only one configuration can match a file. + */ + filesSet.forEach((file) => { + if (isInsideDir(file)) { + scopedFiles.add(file) + } + }) + + 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 4d2c25744..40c2d5c51 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,9 +1,19 @@ 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') @@ -78,7 +88,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 bc8e05bbd..6083f77cf 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/runAll.js b/lib/runAll.js index c82e1cb30..b9c54f7da 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 bfca1c4e3..ada2e426d 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 } diff --git a/test/getConfigGroups.spec.js b/test/getConfigGroups.spec.js deleted file mode 100644 index 70ada8e7e..000000000 --- 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 94cb47126..383f8bb3f 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/integration.test.js b/test/integration.test.js index 68ab7088c..1fb4cb52e 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] /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] /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... @@ -792,12 +792,12 @@ describe('lint-staged', () => { LOG [STARTED] Preparing lint-staged... LOG [SUCCESS] Preparing lint-staged... LOG [STARTED] Running tasks for staged files... - LOG [STARTED] /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] /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 +832,14 @@ describe('lint-staged', () => { LOG [STARTED] Preparing lint-staged... LOG [SUCCESS] Preparing lint-staged... LOG [STARTED] Running tasks for staged files... - LOG [STARTED] /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] /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 +982,12 @@ describe('lint-staged', () => { LOG [STARTED] Preparing lint-staged... LOG [SUCCESS] Preparing lint-staged... LOG [STARTED] Running tasks for staged files... - LOG [STARTED] /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] /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..." @@ -1027,12 +1027,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] /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] /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 +1188,44 @@ 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')).toMatch('') + + // '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')).toMatch('') + }) + 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 +1269,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 +1286,12 @@ describe('lintStaged', () => { LOG [STARTED] Preparing lint-staged... LOG [SUCCESS] Preparing lint-staged... LOG [STARTED] Running tasks for staged files... - LOG [STARTED] /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] /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/runAll.spec.js b/test/runAll.spec.js index 6865939de..f890383aa 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 000000000..74be8a143 --- /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]) + }) +})