diff --git a/lib/getConfigGroups.js b/lib/getConfigGroups.js index db1ce142a..eff2460eb 100644 --- a/lib/getConfigGroups.js +++ b/lib/getConfigGroups.js @@ -99,13 +99,6 @@ export const getConfigGroups = async ( // 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/loadConfig.js b/lib/loadConfig.js index 78fa3c767..43e281eff 100644 --- a/lib/loadConfig.js +++ b/lib/loadConfig.js @@ -13,7 +13,7 @@ const debugLog = debug('lint-staged:loadConfig') * The list of files `lint-staged` will read configuration * from, in the declared order. */ -const searchPlaces = [ +export const searchPlaces = [ 'package.json', '.lintstagedrc', '.lintstagedrc.json', diff --git a/lib/runAll.js b/lib/runAll.js index 1e282630a..db2d1c830 100644 --- a/lib/runAll.js +++ b/lib/runAll.js @@ -35,7 +35,8 @@ import { restoreOriginalStateSkipped, restoreUnstagedChangesSkipped, } from './state.js' -import { GitRepoError, GetStagedFilesError, GitError } from './symbols.js' +import { GitRepoError, GetStagedFilesError, GitError, ConfigNotFoundError } from './symbols.js' +import { searchConfigs } from './searchConfigs.js' const debugLog = debug('lint-staged:runAll') @@ -121,7 +122,19 @@ export const runAll = async ( const configGroups = await getConfigGroups({ configObject, configPath, cwd, files }, logger) - const hasMultipleConfigs = Object.keys(configGroups).length > 1 + const hasExplicitConfig = configObject || configPath + const foundConfigs = hasExplicitConfig ? null : await searchConfigs(gitDir, logger) + const numberOfConfigs = hasExplicitConfig ? 1 : Object.keys(foundConfigs).length + + // Throw if no configurations were found + if (numberOfConfigs === 0) { + ctx.errors.add(ConfigNotFoundError) + throw createError(ctx, ConfigNotFoundError) + } + + debugLog('Found %d configs:\n%O', numberOfConfigs, foundConfigs) + + const hasMultipleConfigs = numberOfConfigs > 1 // lint-staged 10 will automatically add modifications to index // Warn user when their command includes `git add` diff --git a/lib/searchConfigs.js b/lib/searchConfigs.js new file mode 100644 index 000000000..063e0e29d --- /dev/null +++ b/lib/searchConfigs.js @@ -0,0 +1,74 @@ +/** @typedef {import('./index').Logger} Logger */ + +import { basename, join } from 'path' + +import normalize from 'normalize-path' + +import { execGit } from './execGit.js' +import { loadConfig, searchPlaces } from './loadConfig.js' +import { validateConfig } from './validateConfig.js' + +const EXEC_GIT = ['ls-files', '-z', '--full-name'] + +const filterPossibleConfigFiles = (file) => searchPlaces.includes(basename(file)) + +const numberOfLevels = (file) => file.split('/').length + +const sortDeepestParth = (a, b) => (numberOfLevels(a) > numberOfLevels(b) ? -1 : 1) + +/** + * Search all config files from the git repository + * + * @param {string} gitDir + * @param {Logger} logger + * @returns {Promise<{ [key: string]: * }>} found configs with filepath as key, and config as value + */ +export const searchConfigs = async (gitDir = process.cwd(), logger) => { + /** Get all possible config files known to git */ + const cachedFiles = (await execGit(EXEC_GIT, { cwd: gitDir })) + // eslint-disable-next-line no-control-regex + .replace(/\u0000$/, '') + .split('\u0000') + .filter(filterPossibleConfigFiles) + + /** Get all possible config files from uncommitted files */ + const otherFiles = ( + await execGit([...EXEC_GIT, '--others', '--exclude-standard'], { cwd: gitDir }) + ) + // eslint-disable-next-line no-control-regex + .replace(/\u0000$/, '') + .split('\u0000') + .filter(filterPossibleConfigFiles) + + /** Sort possible config files so that deepest is first */ + const possibleConfigFiles = [...cachedFiles, ...otherFiles] + .map((file) => join(gitDir, file)) + .map((file) => normalize(file)) + .sort(sortDeepestParth) + + /** Create object with key as config file, and value as null */ + const configs = possibleConfigFiles.reduce( + (acc, configPath) => Object.assign(acc, { [configPath]: null }), + {} + ) + + /** Load and validate all configs to the above object */ + await Promise.all( + possibleConfigFiles + .map((configPath) => loadConfig({ configPath }, logger)) + .map((promise) => + promise.then(({ config, filepath }) => { + if (config) { + configs[filepath] = validateConfig(config, filepath, logger) + } + }) + ) + ) + + /** Get validated configs from the above object, without any `null` values (not found) */ + const foundConfigs = Object.entries(configs) + .filter(([, value]) => !!value) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}) + + return foundConfigs +} diff --git a/test/getConfigGroups.spec.js b/test/getConfigGroups.spec.js index 6f1ceddd9..70ada8e7e 100644 --- a/test/getConfigGroups.spec.js +++ b/test/getConfigGroups.spec.js @@ -30,12 +30,6 @@ describe('getConfigGroups', () => { ) }) - it('should throw when config not found', async () => { - await expect( - getConfigGroups({ files: ['/foo.js'] }) - ).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' }) diff --git a/test/runAll.spec.js b/test/runAll.spec.js index 01168ecea..84fa9c4ae 100644 --- a/test/runAll.spec.js +++ b/test/runAll.spec.js @@ -8,7 +8,8 @@ import { getStagedFiles } from '../lib/getStagedFiles' import { GitWorkflow } from '../lib/gitWorkflow' import { resolveGitRepo } from '../lib/resolveGitRepo' import { runAll } from '../lib/runAll' -import { GitError } from '../lib/symbols' +import { ConfigNotFoundError, GitError } from '../lib/symbols' +import * as searchConfigsNS from '../lib/searchConfigs' import * as getConfigGroupsNS from '../lib/getConfigGroups' jest.mock('../lib/file') @@ -27,6 +28,7 @@ jest.mock('../lib/resolveConfig', () => ({ }, })) +const searchConfigs = jest.spyOn(searchConfigsNS, 'searchConfigs') const getConfigGroups = jest.spyOn(getConfigGroupsNS, 'getConfigGroups') getStagedFiles.mockImplementation(async () => []) @@ -277,17 +279,18 @@ describe('runAll', () => { const cwd = process.cwd() // For the test, set cwd in test/ const innerCwd = path.join(cwd, 'test/') - try { - // Run lint-staged in `innerCwd` with relative option - // This means the sample task will receive `foo.js` - await runAll({ + + // Run lint-staged in `innerCwd` with relative option + // This means the sample task will receive `foo.js` + await expect( + runAll({ configObject: { '*.js': mockTask }, configPath, stash: false, relative: true, cwd: innerCwd, }) - } catch {} // eslint-disable-line no-empty + ).rejects.toThrowError() // task received relative `foo.js` expect(mockTask).toHaveBeenCalledTimes(1) @@ -313,17 +316,22 @@ describe('runAll', () => { }, }) + searchConfigs.mockResolvedValueOnce({ + '.lintstagedrc.json': { '*.js': mockTask }, + 'test/.lintstagedrc.json': { '*.js': mockTask }, + }) + // We are only interested in the `matchedFileChunks` generation let expected const mockConstructor = jest.fn(({ matchedFileChunks }) => (expected = matchedFileChunks)) GitWorkflow.mockImplementationOnce(mockConstructor) - try { - await runAll({ + await expect( + runAll({ stash: false, relative: true, }) - } catch {} // eslint-disable-line no-empty + ).rejects.toThrowError() // task received relative `foo.js` from both directories expect(mockTask).toHaveBeenCalledTimes(2) @@ -355,17 +363,42 @@ describe('runAll', () => { }, }) - try { - await runAll({ + searchConfigs.mockResolvedValueOnce({ + '.lintstagedrc.json': { '*.js': mockTask }, + 'test/.lintstagedrc.json': { '*.js': mockTask }, + }) + + await expect( + runAll({ cwd: '.', stash: false, relative: true, }) - } catch {} // eslint-disable-line no-empty + ).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']) }) + + it('should error when no configurations found', async () => { + getStagedFiles.mockImplementationOnce(async () => ['foo.js', 'test/foo.js']) + + getConfigGroups.mockResolvedValueOnce({}) + + searchConfigs.mockResolvedValueOnce({}) + + expect.assertions(1) + + try { + await runAll({ + cwd: '.', + stash: false, + relative: true, + }) + } catch ({ ctx }) { + expect(ctx.errors.has(ConfigNotFoundError)).toBe(true) + } + }) })