Skip to content

Commit 90d1035

Browse files
committed
feat: support multiple configuration files
1 parent f9f6538 commit 90d1035

19 files changed

+748
-516
lines changed

README.md

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22

33
Run linters against staged git files and don't let :poop: slip into your code base!
44

5+
```
6+
$ git commit
7+
8+
✔ Preparing...
9+
❯ Running tasks...
10+
❯ packages/frontend/.lintstagedrc.json — 1 file
11+
↓ *.js — no files [SKIPPED]
12+
❯ *.{json,md} — 1 file
13+
⠹ prettier --write
14+
↓ packages/backend/.lintstagedrc.json — 2 files
15+
❯ *.js — 2 files
16+
⠼ eslint --fix
17+
↓ *.{json,md} — no files [SKIPPED]
18+
◼ Applying modifications...
19+
◼ Cleaning up...
20+
```
21+
522
[![asciicast](https://asciinema.org/a/199934.svg)](https://asciinema.org/a/199934)
623

724
## Why
@@ -116,6 +133,8 @@ Starting with v3.1 you can now use different ways of configuring lint-staged:
116133
117134
Configuration should be an object where each value is a command to run and its key is a glob pattern to use for this command. This package uses [micromatch](https://github.com/micromatch/micromatch) for glob patterns. JavaScript files can also export advanced configuration as a function. See [Using JS configuration files](#using-js-configuration-files) for more info.
118135
136+
You can also place multiple configuration files in different directories inside a project. For a given staged file, the closest configuration file will always be used. See ["How to use `lint-staged` in a multi-package monorepo?"](#how-to-use-lint-staged-in-a-multi-package-monorepo) for more info and an example.
137+
119138
#### `package.json` example:
120139
121140
```json
@@ -644,12 +663,32 @@ _Thanks to [this comment](https://youtrack.jetbrains.com/issue/IDEA-135454#comme
644663
<details>
645664
<summary>Click to expand</summary>
646665
647-
Starting with v5.0, `lint-staged` automatically resolves the git root **without any** additional configuration. You configure `lint-staged` as you normally would if your project root and git root were the same directory.
666+
Install _lint-staged_ on the monorepo root level, and add separate configuration files in each package. When running, _lint-staged_ will always use the configuration closest to a staged file, so having separate configuration files makes sure linters do not "leak" into other packages.
667+
668+
For example, in a monorepo with `packages/frontend/.lintstagedrc.json` and `packages/backend/.lintstagedrc.json`, a staged file inside `packages/frontend/` will only match that configuration, and not the one in `packages/backend/`.
669+
670+
**Note**: _lint-staged_ discovers the closest configuration to each staged file, even if that configuration doesn't include any matching globs. Given these example configurations:
671+
672+
```js
673+
// ./.lintstagedrc.json
674+
{ "*.md": "prettier --write" }
675+
```
676+
677+
```js
678+
// ./packages/frontend/.lintstagedrc.json
679+
{ "*.js": "eslint --fix" }
680+
```
648681
649-
If you wish to use `lint-staged` in a multi package monorepo, it is recommended to install [`husky`](https://github.com/typicode/husky) in the root package.json.
650-
[`lerna`](https://github.com/lerna/lerna) can be used to execute the `precommit` script in all sub-packages.
682+
When committing `./packages/frontend/README.md`, it **will not run** _prettier_, because the configuration in the `frontend/` directory is closer to the file and doesn't include it. You should treat all _lint-staged_ configuration files as isolated and separated from each other. You can always use JS files to "extend" configurations, for example:
651683
652-
Example repo: [sudo-suhas/lint-staged-multi-pkg](https://github.com/sudo-suhas/lint-staged-multi-pkg).
684+
```js
685+
import baseConfig from '../.lintstagedrc.js'
686+
687+
export default {
688+
...baseConfig,
689+
'*.js': 'eslint --fix',
690+
}
691+
```
653692
654693
</details>
655694

lib/dynamicImport.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { pathToFileURL } from 'url'
2+
3+
export const dynamicImport = (path) => import(pathToFileURL(path)).then((module) => module.default)

lib/generateTasks.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ const debugLog = debug('lint-staged:generateTasks')
1616
* @param {boolean} [options.files] - Staged filepaths
1717
* @param {boolean} [options.relative] - Whether filepaths to should be relative to gitDir
1818
*/
19-
export const generateTasks = ({ config, cwd = process.cwd(), gitDir, files, relative = false }) => {
19+
export const generateTasks = ({ config, cwd = process.cwd(), files, relative = false }) => {
2020
debugLog('Generating linter tasks')
2121

22-
const absoluteFiles = files.map((file) => normalize(path.resolve(gitDir, file)))
23-
const relativeFiles = absoluteFiles.map((file) => normalize(path.relative(cwd, file)))
22+
const relativeFiles = files.map((file) => normalize(path.relative(cwd, file)))
2423

2524
return Object.entries(config).map(([rawPattern, commands]) => {
2625
let pattern = rawPattern

lib/getConfigGroups.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/** @typedef {import('./index').Logger} Logger */
2+
3+
import path from 'path'
4+
5+
import { loadConfig } from './loadConfig.js'
6+
import { ConfigNotFoundError } from './symbols.js'
7+
import { validateConfig } from './validateConfig.js'
8+
9+
/**
10+
* Return matched files grouped by their configuration.
11+
*
12+
* @param {object} options
13+
* @param {Object} [options.configObject] - Explicit config object from the js API
14+
* @param {string} [options.configPath] - Explicit path to a config file
15+
* @param {string} [options.cwd] - Current working directory
16+
* @param {Logger} logger
17+
*/
18+
export const getConfigGroups = async ({ configObject, configPath, files }, logger = console) => {
19+
// Return explicit config object from js API
20+
if (configObject) {
21+
const config = validateConfig(configObject, 'config object', logger)
22+
return { '': { config, files } }
23+
}
24+
25+
// Use only explicit config path instead of discovering multiple
26+
if (configPath) {
27+
const { config, filepath } = await loadConfig({ configPath }, logger)
28+
29+
if (!config) {
30+
logger.error(`${ConfigNotFoundError.message}.`)
31+
throw ConfigNotFoundError
32+
}
33+
34+
const validatedConfig = validateConfig(config, filepath, logger)
35+
return { [configPath]: { config: validatedConfig, files } }
36+
}
37+
38+
// Group files by their base directory
39+
const filesByDir = files.reduce((acc, file) => {
40+
const dir = path.normalize(path.dirname(file))
41+
42+
if (dir in acc) {
43+
acc[dir].push(file)
44+
} else {
45+
acc[dir] = [file]
46+
}
47+
48+
return acc
49+
}, {})
50+
51+
// Group files by their discovered config
52+
// { '.lintstagedrc.json': { config: {...}, files: [...] } }
53+
const configGroups = {}
54+
55+
for (const [dir, files] of Object.entries(filesByDir)) {
56+
// Discover config from the base directory of the file
57+
const { config, filepath } = await loadConfig({ cwd: dir }, logger)
58+
59+
if (!config) {
60+
logger.error(`${ConfigNotFoundError.message}.`)
61+
throw ConfigNotFoundError
62+
}
63+
64+
if (filepath in configGroups) {
65+
// Re-use cached config and skip validation
66+
configGroups[filepath].files.push(...files)
67+
continue
68+
}
69+
70+
const validatedConfig = validateConfig(config, filepath, logger)
71+
configGroups[filepath] = { config: validatedConfig, files }
72+
}
73+
74+
return configGroups
75+
}

lib/getStagedFiles.js

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
1+
import path from 'path'
2+
3+
import normalize from 'normalize-path'
4+
15
import { execGit } from './execGit.js'
26

3-
export const getStagedFiles = async (options) => {
7+
export const getStagedFiles = async ({ cwd = process.cwd() } = {}) => {
48
try {
59
// Docs for --diff-filter option: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---diff-filterACDMRTUXB82308203
610
// Docs for -z option: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt--z
7-
const lines = await execGit(
8-
['diff', '--staged', '--diff-filter=ACMR', '--name-only', '-z'],
9-
options
11+
const lines = await execGit(['diff', '--staged', '--diff-filter=ACMR', '--name-only', '-z'], {
12+
cwd,
13+
})
14+
15+
if (!lines) return []
16+
17+
// With `-z`, git prints `fileA\u0000fileB\u0000fileC\u0000` so we need to
18+
// remove the last occurrence of `\u0000` before splitting
19+
return (
20+
lines
21+
// eslint-disable-next-line no-control-regex
22+
.replace(/\u0000$/, '')
23+
.split('\u0000')
24+
.map((file) => normalize(path.resolve(cwd, file)))
1025
)
11-
// With `-z`, git prints `fileA\u0000fileB\u0000fileC\u0000` so we need to remove the last occurrence of `\u0000` before splitting
12-
// eslint-disable-next-line no-control-regex
13-
return lines ? lines.replace(/\u0000$/, '').split('\u0000') : []
1426
} catch {
1527
return null
1628
}

lib/index.js

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
11
import debug from 'debug'
2-
import inspect from 'object-inspect'
32

4-
import { loadConfig } from './loadConfig.js'
53
import { PREVENTED_EMPTY_COMMIT, GIT_ERROR, RESTORE_STASH_EXAMPLE } from './messages.js'
64
import { printTaskOutput } from './printTaskOutput.js'
75
import { runAll } from './runAll.js'
8-
import {
9-
ApplyEmptyCommitError,
10-
ConfigNotFoundError,
11-
GetBackupStashError,
12-
GitError,
13-
} from './symbols.js'
14-
import { validateConfig } from './validateConfig.js'
6+
import { ApplyEmptyCommitError, GetBackupStashError, GitError } from './symbols.js'
157
import { validateOptions } from './validateOptions.js'
168

179
const debugLog = debug('lint-staged')
@@ -58,25 +50,6 @@ const lintStaged = async (
5850
) => {
5951
await validateOptions({ shell }, logger)
6052

61-
const inputConfig = configObject || (await loadConfig({ configPath, cwd }, logger))
62-
63-
if (!inputConfig) {
64-
logger.error(`${ConfigNotFoundError.message}.`)
65-
throw ConfigNotFoundError
66-
}
67-
68-
const config = validateConfig(inputConfig, logger)
69-
70-
if (debug) {
71-
// Log using logger to be able to test through `consolemock`.
72-
logger.log('Running lint-staged with the following config:')
73-
logger.log(inspect(config, { indent: 2 }))
74-
} else {
75-
// We might not be in debug mode but `DEBUG=lint-staged*` could have
76-
// been set.
77-
debugLog('lint-staged config:\n%O', config)
78-
}
79-
8053
// Unset GIT_LITERAL_PATHSPECS to not mess with path interpretation
8154
debugLog('Unset GIT_LITERAL_PATHSPECS (was `%s`)', process.env.GIT_LITERAL_PATHSPECS)
8255
delete process.env.GIT_LITERAL_PATHSPECS
@@ -86,7 +59,8 @@ const lintStaged = async (
8659
{
8760
allowEmpty,
8861
concurrent,
89-
config,
62+
configObject,
63+
configPath,
9064
cwd,
9165
debug,
9266
maxArgLength,

lib/loadConfig.js

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
/** @typedef {import('./index').Logger} Logger */
22

3-
import { pathToFileURL } from 'url'
4-
53
import debug from 'debug'
64
import { lilconfig } from 'lilconfig'
75
import YAML from 'yaml'
86

7+
import { dynamicImport } from './dynamicImport.js'
98
import { resolveConfig } from './resolveConfig.js'
109

1110
const debugLog = debug('lint-staged:loadConfig')
@@ -28,9 +27,6 @@ const searchPlaces = [
2827
'lint-staged.config.cjs',
2928
]
3029

31-
/** exported for tests */
32-
export const dynamicImport = (path) => import(pathToFileURL(path)).then((module) => module.default)
33-
3430
const jsonParse = (path, content) => JSON.parse(content)
3531

3632
const yamlParse = (path, content) => YAML.parse(content)
@@ -51,6 +47,8 @@ const loaders = {
5147
noExt: yamlParse,
5248
}
5349

50+
const explorer = lilconfig('lint-staged', { searchPlaces, loaders })
51+
5452
/**
5553
* @param {object} options
5654
* @param {string} [options.configPath] - Explicit path to a config file
@@ -64,22 +62,22 @@ export const loadConfig = async ({ configPath, cwd }, logger) => {
6462
debugLog('Searching for configuration from `%s`...', cwd)
6563
}
6664

67-
const explorer = lilconfig('lint-staged', { searchPlaces, loaders })
68-
6965
const result = await (configPath
7066
? explorer.load(resolveConfig(configPath))
7167
: explorer.search(cwd))
72-
if (!result) return null
68+
69+
if (!result) return {}
7370

7471
// config is a promise when using the `dynamicImport` loader
7572
const config = await result.config
73+
const filepath = result.filepath
7674

77-
debugLog('Successfully loaded config from `%s`:\n%O', result.filepath, config)
75+
debugLog('Successfully loaded config from `%s`:\n%O', filepath, config)
7876

79-
return config
77+
return { config, filepath }
8078
} catch (error) {
8179
debugLog('Failed to load configuration!')
8280
logger.error(error)
83-
return null
81+
return {}
8482
}
8583
}

0 commit comments

Comments
 (0)