Skip to content

Commit 4b605cd

Browse files
committed
fix: search all configs regardless of staged files
Make sure lint-staged doesn't exit with error when staging files not covered by any config, as long as there exists at least a single config.
1 parent 3395150 commit 4b605cd

File tree

6 files changed

+135
-28
lines changed

6 files changed

+135
-28
lines changed

lib/getConfigGroups.js

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,6 @@ export const getConfigGroups = async (
9999
// Discover configs from the base directory of each file
100100
await Promise.all(Object.entries(filesByDir).map(([dir, files]) => searchConfig(dir, files)))
101101

102-
// Throw if no configurations were found
103-
if (Object.keys(configGroups).length === 0) {
104-
debugLog('Found no config groups!')
105-
logger.error(`${ConfigNotFoundError.message}.`)
106-
throw ConfigNotFoundError
107-
}
108-
109102
debugLog('Grouped staged files into %d groups!', Object.keys(configGroups).length)
110103

111104
return configGroups

lib/loadConfig.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const debugLog = debug('lint-staged:loadConfig')
1313
* The list of files `lint-staged` will read configuration
1414
* from, in the declared order.
1515
*/
16-
const searchPlaces = [
16+
export const searchPlaces = [
1717
'package.json',
1818
'.lintstagedrc',
1919
'.lintstagedrc.json',

lib/runAll.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ import {
3535
restoreOriginalStateSkipped,
3636
restoreUnstagedChangesSkipped,
3737
} from './state.js'
38-
import { GitRepoError, GetStagedFilesError, GitError } from './symbols.js'
38+
import { GitRepoError, GetStagedFilesError, GitError, ConfigNotFoundError } from './symbols.js'
39+
import { searchConfigs } from './searchConfigs.js'
3940

4041
const debugLog = debug('lint-staged:runAll')
4142

@@ -121,7 +122,19 @@ export const runAll = async (
121122

122123
const configGroups = await getConfigGroups({ configObject, configPath, cwd, files }, logger)
123124

124-
const hasMultipleConfigs = Object.keys(configGroups).length > 1
125+
const hasExplicitConfig = configObject || configPath
126+
const foundConfigs = hasExplicitConfig ? null : await searchConfigs(gitDir, logger)
127+
const numberOfConfigs = hasExplicitConfig ? 1 : Object.keys(foundConfigs).length
128+
129+
// Throw if no configurations were found
130+
if (numberOfConfigs === 0) {
131+
ctx.errors.add(ConfigNotFoundError)
132+
throw createError(ctx, ConfigNotFoundError)
133+
}
134+
135+
debugLog('Found %d configs:\n%O', numberOfConfigs, foundConfigs)
136+
137+
const hasMultipleConfigs = numberOfConfigs > 1
125138

126139
// lint-staged 10 will automatically add modifications to index
127140
// Warn user when their command includes `git add`

lib/searchConfigs.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/** @typedef {import('./index').Logger} Logger */
2+
3+
import { basename, join } from 'path'
4+
5+
import normalize from 'normalize-path'
6+
7+
import { execGit } from './execGit.js'
8+
import { loadConfig, searchPlaces } from './loadConfig.js'
9+
import { validateConfig } from './validateConfig.js'
10+
11+
const EXEC_GIT = ['ls-files', '-z', '--full-name']
12+
13+
const filterPossibleConfigFiles = (file) => searchPlaces.includes(basename(file))
14+
15+
const numberOfLevels = (file) => file.split('/').length
16+
17+
const sortDeepestParth = (a, b) => (numberOfLevels(a) > numberOfLevels(b) ? -1 : 1)
18+
19+
/**
20+
* Search all config files from the git repository
21+
*
22+
* @param {string} gitDir
23+
* @param {Logger} logger
24+
* @returns {Promise<{ [key: string]: * }>} found configs with filepath as key, and config as value
25+
*/
26+
export const searchConfigs = async (gitDir = process.cwd(), logger) => {
27+
/** Get all possible config files known to git */
28+
const cachedFiles = (await execGit(EXEC_GIT, { cwd: gitDir }))
29+
// eslint-disable-next-line no-control-regex
30+
.replace(/\u0000$/, '')
31+
.split('\u0000')
32+
.filter(filterPossibleConfigFiles)
33+
34+
/** Get all possible config files from uncommitted files */
35+
const otherFiles = (
36+
await execGit([...EXEC_GIT, '--others', '--exclude-standard'], { cwd: gitDir })
37+
)
38+
// eslint-disable-next-line no-control-regex
39+
.replace(/\u0000$/, '')
40+
.split('\u0000')
41+
.filter(filterPossibleConfigFiles)
42+
43+
/** Sort possible config files so that deepest is first */
44+
const possibleConfigFiles = [...cachedFiles, ...otherFiles]
45+
.map((file) => join(gitDir, file))
46+
.map((file) => normalize(file))
47+
.sort(sortDeepestParth)
48+
49+
/** Create object with key as config file, and value as null */
50+
const configs = possibleConfigFiles.reduce(
51+
(acc, configPath) => Object.assign(acc, { [configPath]: null }),
52+
{}
53+
)
54+
55+
/** Load and validate all configs to the above object */
56+
await Promise.all(
57+
possibleConfigFiles
58+
.map((configPath) => loadConfig({ configPath }, logger))
59+
.map((promise) =>
60+
promise.then(({ config, filepath }) => {
61+
if (config) {
62+
configs[filepath] = validateConfig(config, filepath, logger)
63+
}
64+
})
65+
)
66+
)
67+
68+
/** Get validated configs from the above object, without any `null` values (not found) */
69+
const foundConfigs = Object.entries(configs)
70+
.filter(([, value]) => !!value)
71+
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
72+
73+
return foundConfigs
74+
}

test/getConfigGroups.spec.js

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,6 @@ describe('getConfigGroups', () => {
3030
)
3131
})
3232

33-
it('should throw when config not found', async () => {
34-
await expect(
35-
getConfigGroups({ files: ['/foo.js'] })
36-
).rejects.toThrowErrorMatchingInlineSnapshot(`"Configuration could not be found"`)
37-
})
38-
3933
it('should find config files for all staged files', async () => {
4034
// Base cwd
4135
loadConfig.mockResolvedValueOnce({ config, filepath: '/.lintstagedrc.json' })

test/runAll.spec.js

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { getStagedFiles } from '../lib/getStagedFiles'
88
import { GitWorkflow } from '../lib/gitWorkflow'
99
import { resolveGitRepo } from '../lib/resolveGitRepo'
1010
import { runAll } from '../lib/runAll'
11-
import { GitError } from '../lib/symbols'
11+
import { ConfigNotFoundError, GitError } from '../lib/symbols'
12+
import * as searchConfigsNS from '../lib/searchConfigs'
1213
import * as getConfigGroupsNS from '../lib/getConfigGroups'
1314

1415
jest.mock('../lib/file')
@@ -27,6 +28,7 @@ jest.mock('../lib/resolveConfig', () => ({
2728
},
2829
}))
2930

31+
const searchConfigs = jest.spyOn(searchConfigsNS, 'searchConfigs')
3032
const getConfigGroups = jest.spyOn(getConfigGroupsNS, 'getConfigGroups')
3133

3234
getStagedFiles.mockImplementation(async () => [])
@@ -277,17 +279,18 @@ describe('runAll', () => {
277279
const cwd = process.cwd()
278280
// For the test, set cwd in test/
279281
const innerCwd = path.join(cwd, 'test/')
280-
try {
281-
// Run lint-staged in `innerCwd` with relative option
282-
// This means the sample task will receive `foo.js`
283-
await runAll({
282+
283+
// Run lint-staged in `innerCwd` with relative option
284+
// This means the sample task will receive `foo.js`
285+
await expect(
286+
runAll({
284287
configObject: { '*.js': mockTask },
285288
configPath,
286289
stash: false,
287290
relative: true,
288291
cwd: innerCwd,
289292
})
290-
} catch {} // eslint-disable-line no-empty
293+
).rejects.toThrowError()
291294

292295
// task received relative `foo.js`
293296
expect(mockTask).toHaveBeenCalledTimes(1)
@@ -313,17 +316,22 @@ describe('runAll', () => {
313316
},
314317
})
315318

319+
searchConfigs.mockResolvedValueOnce({
320+
'.lintstagedrc.json': { '*.js': mockTask },
321+
'test/.lintstagedrc.json': { '*.js': mockTask },
322+
})
323+
316324
// We are only interested in the `matchedFileChunks` generation
317325
let expected
318326
const mockConstructor = jest.fn(({ matchedFileChunks }) => (expected = matchedFileChunks))
319327
GitWorkflow.mockImplementationOnce(mockConstructor)
320328

321-
try {
322-
await runAll({
329+
await expect(
330+
runAll({
323331
stash: false,
324332
relative: true,
325333
})
326-
} catch {} // eslint-disable-line no-empty
334+
).rejects.toThrowError()
327335

328336
// task received relative `foo.js` from both directories
329337
expect(mockTask).toHaveBeenCalledTimes(2)
@@ -355,17 +363,42 @@ describe('runAll', () => {
355363
},
356364
})
357365

358-
try {
359-
await runAll({
366+
searchConfigs.mockResolvedValueOnce({
367+
'.lintstagedrc.json': { '*.js': mockTask },
368+
'test/.lintstagedrc.json': { '*.js': mockTask },
369+
})
370+
371+
await expect(
372+
runAll({
360373
cwd: '.',
361374
stash: false,
362375
relative: true,
363376
})
364-
} catch {} // eslint-disable-line no-empty
377+
).rejects.toThrowError()
365378

366379
expect(mockTask).toHaveBeenCalledTimes(2)
367380
expect(mockTask).toHaveBeenNthCalledWith(1, ['foo.js'])
368381
// This is now relative to "." instead of "test/"
369382
expect(mockTask).toHaveBeenNthCalledWith(2, ['test/foo.js'])
370383
})
384+
385+
it('should error when no configurations found', async () => {
386+
getStagedFiles.mockImplementationOnce(async () => ['foo.js', 'test/foo.js'])
387+
388+
getConfigGroups.mockResolvedValueOnce({})
389+
390+
searchConfigs.mockResolvedValueOnce({})
391+
392+
expect.assertions(1)
393+
394+
try {
395+
await runAll({
396+
cwd: '.',
397+
stash: false,
398+
relative: true,
399+
})
400+
} catch ({ ctx }) {
401+
expect(ctx.errors.has(ConfigNotFoundError)).toBe(true)
402+
}
403+
})
371404
})

0 commit comments

Comments
 (0)