Skip to content
Permalink
Browse files
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.
  • Loading branch information
iiroj committed Mar 5, 2022
1 parent 3395150 commit 4b605cd3694cc5bfcf6c5a1a2e75c80ef234ab1a
Showing 6 changed files with 135 additions and 28 deletions.
@@ -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
@@ -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',
@@ -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`
@@ -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
}
@@ -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' })
@@ -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)
}
})
})

0 comments on commit 4b605cd

Please sign in to comment.