Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
feat: support multiple configuration files
  • Loading branch information
iiroj committed Jan 18, 2022
1 parent f9f6538 commit 90d1035
Show file tree
Hide file tree
Showing 19 changed files with 748 additions and 516 deletions.
47 changes: 43 additions & 4 deletions README.md
Expand Up @@ -2,6 +2,23 @@

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

```
$ git commit
✔ Preparing...
❯ Running tasks...
❯ packages/frontend/.lintstagedrc.json — 1 file
↓ *.js — no files [SKIPPED]
❯ *.{json,md} — 1 file
⠹ prettier --write
↓ packages/backend/.lintstagedrc.json — 2 files
❯ *.js — 2 files
⠼ eslint --fix
↓ *.{json,md} — no files [SKIPPED]
◼ Applying modifications...
◼ Cleaning up...
```

[![asciicast](https://asciinema.org/a/199934.svg)](https://asciinema.org/a/199934)

## Why
Expand Down Expand Up @@ -116,6 +133,8 @@ Starting with v3.1 you can now use different ways of configuring lint-staged:
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.
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.
#### `package.json` example:
```json
Expand Down Expand Up @@ -644,12 +663,32 @@ _Thanks to [this comment](https://youtrack.jetbrains.com/issue/IDEA-135454#comme
<details>
<summary>Click to expand</summary>
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.
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.
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/`.
**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:
```js
// ./.lintstagedrc.json
{ "*.md": "prettier --write" }
```
```js
// ./packages/frontend/.lintstagedrc.json
{ "*.js": "eslint --fix" }
```
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.
[`lerna`](https://github.com/lerna/lerna) can be used to execute the `precommit` script in all sub-packages.
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:
Example repo: [sudo-suhas/lint-staged-multi-pkg](https://github.com/sudo-suhas/lint-staged-multi-pkg).
```js
import baseConfig from '../.lintstagedrc.js'
export default {
...baseConfig,
'*.js': 'eslint --fix',
}
```
</details>
Expand Down
3 changes: 3 additions & 0 deletions lib/dynamicImport.js
@@ -0,0 +1,3 @@
import { pathToFileURL } from 'url'

export const dynamicImport = (path) => import(pathToFileURL(path)).then((module) => module.default)
5 changes: 2 additions & 3 deletions lib/generateTasks.js
Expand Up @@ -16,11 +16,10 @@ const debugLog = debug('lint-staged:generateTasks')
* @param {boolean} [options.files] - Staged filepaths
* @param {boolean} [options.relative] - Whether filepaths to should be relative to gitDir
*/
export const generateTasks = ({ config, cwd = process.cwd(), gitDir, files, relative = false }) => {
export const generateTasks = ({ config, cwd = process.cwd(), files, relative = false }) => {
debugLog('Generating linter tasks')

const absoluteFiles = files.map((file) => normalize(path.resolve(gitDir, file)))
const relativeFiles = absoluteFiles.map((file) => normalize(path.relative(cwd, file)))
const relativeFiles = files.map((file) => normalize(path.relative(cwd, file)))

return Object.entries(config).map(([rawPattern, commands]) => {
let pattern = rawPattern
Expand Down
75 changes: 75 additions & 0 deletions lib/getConfigGroups.js
@@ -0,0 +1,75 @@
/** @typedef {import('./index').Logger} Logger */

import path from 'path'

import { loadConfig } from './loadConfig.js'
import { ConfigNotFoundError } from './symbols.js'
import { validateConfig } from './validateConfig.js'

/**
* 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 {Logger} logger
*/
export const getConfigGroups = async ({ configObject, configPath, files }, logger = console) => {
// Return explicit config object from js API
if (configObject) {
const config = validateConfig(configObject, 'config object', logger)
return { '': { config, files } }
}

// Use only explicit config path instead of discovering multiple
if (configPath) {
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 } }
}

// 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
}, {})

// Group files by their discovered config
// { '.lintstagedrc.json': { config: {...}, files: [...] } }
const configGroups = {}

for (const [dir, files] of Object.entries(filesByDir)) {
// Discover config from the base directory of the file
const { config, filepath } = await loadConfig({ cwd: dir }, logger)

if (!config) {
logger.error(`${ConfigNotFoundError.message}.`)
throw ConfigNotFoundError
}

if (filepath in configGroups) {
// Re-use cached config and skip validation
configGroups[filepath].files.push(...files)
continue
}

const validatedConfig = validateConfig(config, filepath, logger)
configGroups[filepath] = { config: validatedConfig, files }
}

return configGroups
}
26 changes: 19 additions & 7 deletions lib/getStagedFiles.js
@@ -1,16 +1,28 @@
import path from 'path'

import normalize from 'normalize-path'

import { execGit } from './execGit.js'

export const getStagedFiles = async (options) => {
export const getStagedFiles = async ({ cwd = process.cwd() } = {}) => {
try {
// Docs for --diff-filter option: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---diff-filterACDMRTUXB82308203
// Docs for -z option: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt--z
const lines = await execGit(
['diff', '--staged', '--diff-filter=ACMR', '--name-only', '-z'],
options
const lines = await execGit(['diff', '--staged', '--diff-filter=ACMR', '--name-only', '-z'], {
cwd,
})

if (!lines) return []

// With `-z`, git prints `fileA\u0000fileB\u0000fileC\u0000` so we need to
// remove the last occurrence of `\u0000` before splitting
return (
lines
// eslint-disable-next-line no-control-regex
.replace(/\u0000$/, '')
.split('\u0000')
.map((file) => normalize(path.resolve(cwd, file)))
)
// With `-z`, git prints `fileA\u0000fileB\u0000fileC\u0000` so we need to remove the last occurrence of `\u0000` before splitting
// eslint-disable-next-line no-control-regex
return lines ? lines.replace(/\u0000$/, '').split('\u0000') : []
} catch {
return null
}
Expand Down
32 changes: 3 additions & 29 deletions lib/index.js
@@ -1,17 +1,9 @@
import debug from 'debug'
import inspect from 'object-inspect'

import { loadConfig } from './loadConfig.js'
import { PREVENTED_EMPTY_COMMIT, GIT_ERROR, RESTORE_STASH_EXAMPLE } from './messages.js'
import { printTaskOutput } from './printTaskOutput.js'
import { runAll } from './runAll.js'
import {
ApplyEmptyCommitError,
ConfigNotFoundError,
GetBackupStashError,
GitError,
} from './symbols.js'
import { validateConfig } from './validateConfig.js'
import { ApplyEmptyCommitError, GetBackupStashError, GitError } from './symbols.js'
import { validateOptions } from './validateOptions.js'

const debugLog = debug('lint-staged')
Expand Down Expand Up @@ -58,25 +50,6 @@ const lintStaged = async (
) => {
await validateOptions({ shell }, logger)

const inputConfig = configObject || (await loadConfig({ configPath, cwd }, logger))

if (!inputConfig) {
logger.error(`${ConfigNotFoundError.message}.`)
throw ConfigNotFoundError
}

const config = validateConfig(inputConfig, logger)

if (debug) {
// Log using logger to be able to test through `consolemock`.
logger.log('Running lint-staged with the following config:')
logger.log(inspect(config, { indent: 2 }))
} else {
// We might not be in debug mode but `DEBUG=lint-staged*` could have
// been set.
debugLog('lint-staged config:\n%O', config)
}

// Unset GIT_LITERAL_PATHSPECS to not mess with path interpretation
debugLog('Unset GIT_LITERAL_PATHSPECS (was `%s`)', process.env.GIT_LITERAL_PATHSPECS)
delete process.env.GIT_LITERAL_PATHSPECS
Expand All @@ -86,7 +59,8 @@ const lintStaged = async (
{
allowEmpty,
concurrent,
config,
configObject,
configPath,
cwd,
debug,
maxArgLength,
Expand Down
20 changes: 9 additions & 11 deletions lib/loadConfig.js
@@ -1,11 +1,10 @@
/** @typedef {import('./index').Logger} Logger */

import { pathToFileURL } from 'url'

import debug from 'debug'
import { lilconfig } from 'lilconfig'
import YAML from 'yaml'

import { dynamicImport } from './dynamicImport.js'
import { resolveConfig } from './resolveConfig.js'

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

/** exported for tests */
export const dynamicImport = (path) => import(pathToFileURL(path)).then((module) => module.default)

const jsonParse = (path, content) => JSON.parse(content)

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

const explorer = lilconfig('lint-staged', { searchPlaces, loaders })

/**
* @param {object} options
* @param {string} [options.configPath] - Explicit path to a config file
Expand All @@ -64,22 +62,22 @@ export const loadConfig = async ({ configPath, cwd }, logger) => {
debugLog('Searching for configuration from `%s`...', cwd)
}

const explorer = lilconfig('lint-staged', { searchPlaces, loaders })

const result = await (configPath
? explorer.load(resolveConfig(configPath))
: explorer.search(cwd))
if (!result) return null

if (!result) return {}

// config is a promise when using the `dynamicImport` loader
const config = await result.config
const filepath = result.filepath

debugLog('Successfully loaded config from `%s`:\n%O', result.filepath, config)
debugLog('Successfully loaded config from `%s`:\n%O', filepath, config)

return config
return { config, filepath }
} catch (error) {
debugLog('Failed to load configuration!')
logger.error(error)
return null
return {}
}
}

0 comments on commit 90d1035

Please sign in to comment.