diff --git a/README.md b/README.md
index 813b3f09b..e3c5ce9ee 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
@@ -644,12 +663,32 @@ _Thanks to [this comment](https://youtrack.jetbrains.com/issue/IDEA-135454#comme
Click to expand
-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',
+}
+```
diff --git a/lib/dynamicImport.js b/lib/dynamicImport.js
new file mode 100644
index 000000000..75e228fc2
--- /dev/null
+++ b/lib/dynamicImport.js
@@ -0,0 +1,3 @@
+import { pathToFileURL } from 'url'
+
+export const dynamicImport = (path) => import(pathToFileURL(path)).then((module) => module.default)
diff --git a/lib/generateTasks.js b/lib/generateTasks.js
index d31413703..164d69b49 100644
--- a/lib/generateTasks.js
+++ b/lib/generateTasks.js
@@ -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
diff --git a/lib/getConfigGroups.js b/lib/getConfigGroups.js
new file mode 100644
index 000000000..6939c2aae
--- /dev/null
+++ b/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
+}
diff --git a/lib/getStagedFiles.js b/lib/getStagedFiles.js
index 69d79aac5..a6a557383 100644
--- a/lib/getStagedFiles.js
+++ b/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
}
diff --git a/lib/index.js b/lib/index.js
index 4048b444d..e01d6d948 100644
--- a/lib/index.js
+++ b/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')
@@ -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
@@ -86,7 +59,8 @@ const lintStaged = async (
{
allowEmpty,
concurrent,
- config,
+ configObject,
+ configPath,
cwd,
debug,
maxArgLength,
diff --git a/lib/loadConfig.js b/lib/loadConfig.js
index 8c9b8c55e..78fa3c767 100644
--- a/lib/loadConfig.js
+++ b/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')
@@ -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)
@@ -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
@@ -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 {}
}
}
diff --git a/lib/runAll.js b/lib/runAll.js
index 1ba726004..04a80cf19 100644
--- a/lib/runAll.js
+++ b/lib/runAll.js
@@ -1,11 +1,16 @@
/** @typedef {import('./index').Logger} Logger */
+import path from 'path'
+
+import { dim } from 'colorette'
import debug from 'debug'
import { Listr } from 'listr2'
+import normalize from 'normalize-path'
import { chunkFiles } from './chunkFiles.js'
import { execGit } from './execGit.js'
import { generateTasks } from './generateTasks.js'
+import { getConfigGroups } from './getConfigGroups.js'
import { getRenderer } from './getRenderer.js'
import { getStagedFiles } from './getStagedFiles.js'
import { GitWorkflow } from './gitWorkflow.js'
@@ -40,10 +45,11 @@ const createError = (ctx) => Object.assign(new Error('lint-staged failed'), { ct
* Executes all tasks and either resolves or rejects the promise
*
* @param {object} options
- * @param {Object} [options.allowEmpty] - Allow empty commits when tasks revert all staged changes
+ * @param {boolean} [options.allowEmpty] - Allow empty commits when tasks revert all staged changes
* @param {boolean | number} [options.concurrent] - The number of tasks to run concurrently, or false to run tasks serially
- * @param {Object} [options.config] - Task configuration
- * @param {Object} [options.cwd] - Current working directory
+ * @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 {boolean} [options.debug] - Enable debug mode
* @param {number} [options.maxArgLength] - Maximum argument string length
* @param {boolean} [options.quiet] - Disable lint-staged’s own console output
@@ -58,7 +64,8 @@ export const runAll = async (
{
allowEmpty = false,
concurrent = true,
- config,
+ configObject,
+ configPath,
cwd = process.cwd(),
debug = false,
maxArgLength,
@@ -107,9 +114,7 @@ export const runAll = async (
return ctx
}
- const stagedFileChunks = chunkFiles({ baseDir: gitDir, files, maxArgLength, relative })
- const chunkCount = stagedFileChunks.length
- if (chunkCount > 1) debugLog(`Chunked staged files into ${chunkCount} part`, chunkCount)
+ const configGroups = await getConfigGroups({ configObject, configPath, files }, logger)
// lint-staged 10 will automatically add modifications to index
// Warn user when their command includes `git add`
@@ -128,62 +133,77 @@ export const runAll = async (
// Set of all staged files that matched a task glob. Values in a set are unique.
const matchedFiles = new Set()
- for (const [index, files] of stagedFileChunks.entries()) {
- const chunkTasks = generateTasks({ config, cwd, gitDir, files, relative })
- const chunkListrTasks = []
-
- for (const task of chunkTasks) {
- const subTasks = await makeCmdTasks({
- commands: task.commands,
- cwd,
- files: task.fileList,
- gitDir,
- renderer: listrOptions.renderer,
- shell,
- verbose,
- })
+ for (const [configPath, { config, files }] of Object.entries(configGroups)) {
+ const stagedFileChunks = chunkFiles({ baseDir: gitDir, files, maxArgLength, relative })
- // Add files from task to match set
- task.fileList.forEach((file) => {
- matchedFiles.add(file)
- })
+ const chunkCount = stagedFileChunks.length
+ if (chunkCount > 1) {
+ debugLog('Chunked staged files from `%s` into %d part', configPath, chunkCount)
+ }
+
+ for (const [index, files] of stagedFileChunks.entries()) {
+ const chunkTasks = generateTasks({ config, cwd, files, relative })
+ const chunkListrTasks = []
+
+ for (const task of chunkTasks) {
+ const subTasks = await makeCmdTasks({
+ commands: task.commands,
+ cwd,
+ files: task.fileList,
+ gitDir,
+ renderer: listrOptions.renderer,
+ shell,
+ verbose,
+ })
+
+ // Add files from task to match set
+ task.fileList.forEach((file) => {
+ matchedFiles.add(file)
+ })
+
+ hasDeprecatedGitAdd =
+ hasDeprecatedGitAdd || subTasks.some((subTask) => subTask.command === 'git add')
- hasDeprecatedGitAdd =
- hasDeprecatedGitAdd || subTasks.some((subTask) => subTask.command === 'git add')
-
- chunkListrTasks.push({
- title: `Running tasks for ${task.pattern}`,
- task: async () =>
- new Listr(subTasks, {
- // In sub-tasks we don't want to run concurrently
- // and we want to abort on errors
- ...listrOptions,
- concurrent: false,
- exitOnError: true,
- }),
+ const fileCount = task.fileList.length
+
+ chunkListrTasks.push({
+ title: `${task.pattern}${dim(` — ${fileCount} ${fileCount > 1 ? 'files' : 'file'}`)}`,
+ task: async () =>
+ new Listr(subTasks, {
+ // In sub-tasks we don't want to run concurrently
+ // and we want to abort on errors
+ ...listrOptions,
+ concurrent: false,
+ exitOnError: true,
+ }),
+ skip: () => {
+ // Skip task when no files matched
+ if (fileCount === 0) {
+ return `${task.pattern}${dim(' — no files')}`
+ }
+ return false
+ },
+ })
+ }
+
+ const relativeConfig = normalize(path.relative(cwd, configPath))
+
+ listrTasks.push({
+ title:
+ `${relativeConfig}${dim(` — ${files.length} ${files.length > 1 ? 'files' : 'file'}`)}` +
+ (chunkCount > 1 ? dim(` (chunk ${index + 1}/${chunkCount})...`) : ''),
+ task: () => new Listr(chunkListrTasks, { ...listrOptions, concurrent, exitOnError: true }),
skip: () => {
- // Skip task when no files matched
- if (task.fileList.length === 0) {
- return `No staged files match ${task.pattern}`
+ // Skip if the first step (backup) failed
+ if (ctx.errors.has(GitError)) return SKIPPED_GIT_ERROR
+ // Skip chunk when no every task is skipped (due to no matches)
+ if (chunkListrTasks.every((task) => task.skip())) {
+ return `${relativeConfig}${dim(' — no tasks to run')}`
}
return false
},
})
}
-
- listrTasks.push({
- // No need to show number of task chunks when there's only one
- title:
- chunkCount > 1 ? `Running tasks (chunk ${index + 1}/${chunkCount})...` : 'Running tasks...',
- task: () => new Listr(chunkListrTasks, { ...listrOptions, concurrent }),
- skip: () => {
- // Skip if the first step (backup) failed
- if (ctx.errors.has(GitError)) return SKIPPED_GIT_ERROR
- // Skip chunk when no every task is skipped (due to no matches)
- if (chunkListrTasks.every((task) => task.skip())) return 'No tasks to run.'
- return false
- },
- })
}
if (hasDeprecatedGitAdd) {
@@ -219,7 +239,11 @@ export const runAll = async (
task: (ctx) => git.hideUnstagedChanges(ctx),
enabled: hasPartiallyStagedFiles,
},
- ...listrTasks,
+ {
+ title: `Running tasks...`,
+ task: () => new Listr(listrTasks, { ...listrOptions, concurrent }),
+ skip: () => listrTasks.every((task) => task.skip()),
+ },
{
title: 'Applying modifications...',
task: (ctx) => git.applyModifications(ctx),
diff --git a/lib/validateConfig.js b/lib/validateConfig.js
index 11e1ceea0..d09382462 100644
--- a/lib/validateConfig.js
+++ b/lib/validateConfig.js
@@ -1,4 +1,7 @@
+/** @typedef {import('./index').Logger} Logger */
+
import debug from 'debug'
+import inspect from 'object-inspect'
import { configurationError } from './messages.js'
import { ConfigEmptyError, ConfigFormatError } from './symbols.js'
@@ -21,11 +24,13 @@ const TEST_DEPRECATED_KEYS = new Map([
/**
* Runs config validation. Throws error if the config is not valid.
- * @param config {Object}
- * @returns config {Object}
+ * @param {Object} config
+ * @param {string} configPath
+ * @param {Logger} logger
+ * @returns {Object} config
*/
-export const validateConfig = (config, logger) => {
- debugLog('Validating config')
+export const validateConfig = (config, configPath, logger) => {
+ debugLog('Validating config from `%s`...', configPath)
if (!config || (typeof config !== 'object' && typeof config !== 'function')) {
throw ConfigFormatError
@@ -103,5 +108,8 @@ See https://github.com/okonet/lint-staged#configuration.`)
throw new Error(message)
}
+ debugLog('Validated config from `%s`:', configPath)
+ debugLog(inspect(config, { indent: 2 }))
+
return validatedConfig
}
diff --git a/test/__snapshots__/validateConfig.spec.js.snap b/test/__snapshots__/validateConfig.spec.js.snap
index 38bf1af6a..734a30346 100644
--- a/test/__snapshots__/validateConfig.spec.js.snap
+++ b/test/__snapshots__/validateConfig.spec.js.snap
@@ -10,6 +10,8 @@ exports[`validateConfig should throw and should print validation errors for inva
exports[`validateConfig should throw and should print validation errors for invalid config 1 1`] = `"Configuration should be an object or a function"`;
+exports[`validateConfig should throw for empty config 1`] = `"Configuration should not be empty"`;
+
exports[`validateConfig should throw when detecting deprecated advanced configuration 1`] = `
"✖ Validation Error:
diff --git a/test/dynamicImport.spec.js b/test/dynamicImport.spec.js
new file mode 100644
index 000000000..9abc014bf
--- /dev/null
+++ b/test/dynamicImport.spec.js
@@ -0,0 +1,7 @@
+import { dynamicImport } from '../lib/dynamicImport'
+
+describe('dynamicImport', () => {
+ it('should log errors into console', () => {
+ expect(() => dynamicImport('not-found.js')).rejects.toThrowError(`Cannot find module`)
+ })
+})
diff --git a/test/generateTasks.spec.js b/test/generateTasks.spec.js
index c8208e077..227c478e0 100644
--- a/test/generateTasks.spec.js
+++ b/test/generateTasks.spec.js
@@ -1,39 +1,34 @@
-import os from 'os'
import path from 'path'
import normalize from 'normalize-path'
import { generateTasks } from '../lib/generateTasks'
-import { resolveGitRepo } from '../lib/resolveGitRepo'
-const normalizePath = (path) => normalize(path)
+// Windows filepaths
+const normalizePath = (input) => normalize(path.resolve('/', input))
+
+const cwd = '/repo'
const files = [
- 'test.js',
- 'deeper/test.js',
- 'deeper/test2.js',
- 'even/deeper/test.js',
- '.hidden/test.js',
-
- 'test.css',
- 'deeper/test1.css',
- 'deeper/test2.css',
- 'even/deeper/test.css',
- '.hidden/test.css',
-
- 'test.txt',
- 'deeper/test.txt',
- 'deeper/test2.txt',
- 'even/deeper/test.txt',
- '.hidden/test.txt',
+ '/repo/test.js',
+ '/repo/deeper/test.js',
+ '/repo/deeper/test2.js',
+ '/repo/even/deeper/test.js',
+ '/repo/.hidden/test.js',
+
+ '/repo/test.css',
+ '/repo/deeper/test1.css',
+ '/repo/deeper/test2.css',
+ '/repo/even/deeper/test.css',
+ '/repo/.hidden/test.css',
+
+ '/repo/test.txt',
+ '/repo/deeper/test.txt',
+ '/repo/deeper/test2.txt',
+ '/repo/even/deeper/test.txt',
+ '/repo/.hidden/test.txt',
]
-// Mocks get hoisted
-jest.mock('../lib/resolveGitRepo.js')
-const gitDir = path.join(os.tmpdir(), 'tmp-lint-staged')
-resolveGitRepo.mockResolvedValue({ gitDir })
-const cwd = gitDir
-
const config = {
'*.js': 'root-js',
'**/*.js': 'any-js',
@@ -50,17 +45,16 @@ describe('generateTasks', () => {
config: {
'*': 'lint',
},
- gitDir,
files,
})
+
task.fileList.forEach((file) => {
expect(path.isAbsolute(file)).toBe(true)
})
})
it('should not match non-children files', async () => {
- const relPath = path.join(process.cwd(), '..')
- const result = await generateTasks({ config, cwd, gitDir: relPath, files })
+ const result = await generateTasks({ config, cwd: '/test', files })
const linter = result.find((item) => item.pattern === '*.js')
expect(linter).toEqual({
pattern: '*.js',
@@ -70,7 +64,7 @@ describe('generateTasks', () => {
})
it('should return an empty file list for linters with no matches.', async () => {
- const result = await generateTasks({ config, cwd, gitDir, files })
+ const result = await generateTasks({ config, cwd, files })
result.forEach((task) => {
if (task.commands === 'unknown-js' || task.commands === 'parent-dir-css-or-js') {
@@ -82,74 +76,74 @@ describe('generateTasks', () => {
})
it('should match pattern "*.js"', async () => {
- const result = await generateTasks({ config, cwd, gitDir, files })
+ const result = await generateTasks({ config, cwd, files })
const linter = result.find((item) => item.pattern === '*.js')
expect(linter).toEqual({
pattern: '*.js',
commands: 'root-js',
fileList: [
- `${gitDir}/test.js`,
- `${gitDir}/deeper/test.js`,
- `${gitDir}/deeper/test2.js`,
- `${gitDir}/even/deeper/test.js`,
- `${gitDir}/.hidden/test.js`,
+ `/repo/test.js`,
+ `/repo/deeper/test.js`,
+ `/repo/deeper/test2.js`,
+ `/repo/even/deeper/test.js`,
+ `/repo/.hidden/test.js`,
].map(normalizePath),
})
})
it('should match pattern "**/*.js"', async () => {
- const result = await generateTasks({ config, cwd, gitDir, files })
+ const result = await generateTasks({ config, cwd, files })
const linter = result.find((item) => item.pattern === '**/*.js')
expect(linter).toEqual({
pattern: '**/*.js',
commands: 'any-js',
fileList: [
- `${gitDir}/test.js`,
- `${gitDir}/deeper/test.js`,
- `${gitDir}/deeper/test2.js`,
- `${gitDir}/even/deeper/test.js`,
- `${gitDir}/.hidden/test.js`,
+ `/repo/test.js`,
+ `/repo/deeper/test.js`,
+ `/repo/deeper/test2.js`,
+ `/repo/even/deeper/test.js`,
+ `/repo/.hidden/test.js`,
].map(normalizePath),
})
})
it('should match pattern "deeper/*.js"', async () => {
- const result = await generateTasks({ config, cwd, gitDir, files })
+ const result = await generateTasks({ config, cwd, files })
const linter = result.find((item) => item.pattern === 'deeper/*.js')
expect(linter).toEqual({
pattern: 'deeper/*.js',
commands: 'deeper-js',
- fileList: [`${gitDir}/deeper/test.js`, `${gitDir}/deeper/test2.js`].map(normalizePath),
+ fileList: [`/repo/deeper/test.js`, `/repo/deeper/test2.js`].map(normalizePath),
})
})
it('should match pattern ".hidden/*.js"', async () => {
- const result = await generateTasks({ config, cwd, gitDir, files })
+ const result = await generateTasks({ config, cwd, files })
const linter = result.find((item) => item.pattern === '.hidden/*.js')
expect(linter).toEqual({
pattern: '.hidden/*.js',
commands: 'hidden-js',
- fileList: [`${gitDir}/.hidden/test.js`].map(normalizePath),
+ fileList: [`/repo/.hidden/test.js`].map(normalizePath),
})
})
it('should match pattern "*.{css,js}"', async () => {
- const result = await generateTasks({ config, cwd, gitDir, files })
+ const result = await generateTasks({ config, cwd, files })
const linter = result.find((item) => item.pattern === '*.{css,js}')
expect(linter).toEqual({
pattern: '*.{css,js}',
commands: 'root-css-or-js',
fileList: [
- `${gitDir}/test.js`,
- `${gitDir}/deeper/test.js`,
- `${gitDir}/deeper/test2.js`,
- `${gitDir}/even/deeper/test.js`,
- `${gitDir}/.hidden/test.js`,
- `${gitDir}/test.css`,
- `${gitDir}/deeper/test1.css`,
- `${gitDir}/deeper/test2.css`,
- `${gitDir}/even/deeper/test.css`,
- `${gitDir}/.hidden/test.css`,
+ `/repo/test.js`,
+ `/repo/deeper/test.js`,
+ `/repo/deeper/test2.js`,
+ `/repo/even/deeper/test.js`,
+ `/repo/.hidden/test.js`,
+ `/repo/test.css`,
+ `/repo/deeper/test1.css`,
+ `/repo/deeper/test2.css`,
+ `/repo/even/deeper/test.css`,
+ `/repo/.hidden/test.css`,
].map(normalizePath),
})
})
@@ -160,7 +154,7 @@ describe('generateTasks', () => {
'test{1..2}.css': 'lint',
},
cwd,
- gitDir,
+
files,
})
@@ -169,42 +163,40 @@ describe('generateTasks', () => {
expect(linter).toEqual({
pattern: 'test{1..2}.css',
commands: 'lint',
- fileList: [`${gitDir}/deeper/test1.css`, `${gitDir}/deeper/test2.css`].map(normalizePath),
+ fileList: [`/repo/deeper/test1.css`, `/repo/deeper/test2.css`].map(normalizePath),
})
})
it('should not match files in parent directory by default', async () => {
const result = await generateTasks({
config,
- cwd: path.join(gitDir, 'deeper'),
- gitDir,
+ cwd: '/repo/deeper',
files,
})
const linter = result.find((item) => item.pattern === '*.js')
expect(linter).toEqual({
pattern: '*.js',
commands: 'root-js',
- fileList: [`${gitDir}/deeper/test.js`, `${gitDir}/deeper/test2.js`].map(normalizePath),
+ fileList: [`/repo/deeper/test.js`, `/repo/deeper/test2.js`].map(normalizePath),
})
})
it('should match files in parent directory when pattern starts with "../"', async () => {
const result = await generateTasks({
config,
- cwd: path.join(gitDir, 'deeper'),
- gitDir,
+ cwd: '/repo/deeper',
files,
})
const linter = result.find((item) => item.pattern === '../*.{css,js}')
expect(linter).toEqual({
commands: 'parent-dir-css-or-js',
- fileList: [`${gitDir}/test.js`, `${gitDir}/test.css`].map(normalizePath),
+ fileList: [`/repo/test.js`, `/repo/test.css`].map(normalizePath),
pattern: '../*.{css,js}',
})
})
it('should be able to return relative paths for "*.{css,js}"', async () => {
- const result = await generateTasks({ config, cwd, gitDir, files, relative: true })
+ const result = await generateTasks({ config, cwd, files, relative: true })
const linter = result.find((item) => item.pattern === '*.{css,js}')
expect(linter).toEqual({
pattern: '*.{css,js}',
@@ -220,7 +212,7 @@ describe('generateTasks', () => {
'deeper/test2.css',
'even/deeper/test.css',
'.hidden/test.css',
- ].map(normalizePath),
+ ],
})
})
})
diff --git a/test/getConfigGroups.spec.js b/test/getConfigGroups.spec.js
new file mode 100644
index 000000000..29af85aa7
--- /dev/null
+++ b/test/getConfigGroups.spec.js
@@ -0,0 +1,56 @@
+import makeConsoleMock from 'consolemock'
+
+import { getConfigGroups } from '../lib/getConfigGroups'
+import { loadConfig } from '../lib/loadConfig'
+
+jest.mock('../lib/loadConfig', () => ({
+ // config not found
+ loadConfig: jest.fn(async () => ({})),
+}))
+
+const globalConsoleTemp = console
+
+const config = {
+ '*.js': 'my-task',
+}
+
+describe('getConfigGroups', () => {
+ beforeEach(() => {
+ console = makeConsoleMock()
+ })
+
+ afterEach(() => {
+ console.printHistory()
+ console = globalConsoleTemp
+ })
+
+ it('should throw when config path not found', async () => {
+ await expect(getConfigGroups({ configPath: '/' })).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"Configuration could not be found"`
+ )
+ })
+
+ 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 () => {
+ // '/foo.js' and '/bar.js'
+ loadConfig.mockResolvedValueOnce({ config, filepath: '/.lintstagedrc.json' })
+ // '/deeper/foo.js'
+ loadConfig.mockResolvedValueOnce({ config, filepath: '/deeper/.lintstagedrc.json' })
+ // '/even/deeper/foo.js'
+ loadConfig.mockResolvedValueOnce({ config, filepath: '/deeper/.lintstagedrc.json' })
+
+ const configGroups = await getConfigGroups({
+ files: ['/foo.js', '/bar.js', '/deeper/foo.js', '/even/deeper/foo.js'],
+ })
+
+ expect(configGroups).toEqual({
+ '/.lintstagedrc.json': { config, files: ['/foo.js', '/bar.js'] },
+ '/deeper/.lintstagedrc.json': { config, files: ['/deeper/foo.js', '/even/deeper/foo.js'] },
+ })
+ })
+})
diff --git a/test/getStagedFiles.spec.js b/test/getStagedFiles.spec.js
index 20f6b9888..15866460d 100644
--- a/test/getStagedFiles.spec.js
+++ b/test/getStagedFiles.spec.js
@@ -1,13 +1,21 @@
+import path from 'path'
+
+import normalize from 'normalize-path'
+
import { getStagedFiles } from '../lib/getStagedFiles'
import { execGit } from '../lib/execGit'
jest.mock('../lib/execGit')
+// Windows filepaths
+const normalizePath = (input) => normalize(path.resolve('/', input))
+
describe('getStagedFiles', () => {
it('should return array of file names', async () => {
execGit.mockImplementationOnce(async () => 'foo.js\u0000bar.js\u0000')
- const staged = await getStagedFiles()
- expect(staged).toEqual(['foo.js', 'bar.js'])
+ const staged = await getStagedFiles({ cwd: '/' })
+ // Windows filepaths
+ expect(staged).toEqual([normalizePath('/foo.js'), normalizePath('/bar.js')])
})
it('should return empty array when no staged files', async () => {
@@ -20,7 +28,7 @@ describe('getStagedFiles', () => {
execGit.mockImplementationOnce(async () => {
throw new Error('fatal: not a git repository (or any of the parent directories): .git')
})
- const staged = await getStagedFiles()
+ const staged = await getStagedFiles({})
expect(staged).toEqual(null)
})
})
diff --git a/test/index.spec.js b/test/index.spec.js
index 81bd5193d..c07c23845 100644
--- a/test/index.spec.js
+++ b/test/index.spec.js
@@ -1,5 +1,3 @@
-import path from 'path'
-
import { lilconfig } from 'lilconfig'
import makeConsoleMock from 'consolemock'
@@ -7,10 +5,6 @@ jest.unmock('execa')
import { getStagedFiles } from '../lib/getStagedFiles'
import lintStaged from '../lib/index'
-import { InvalidOptionsError } from '../lib/symbols'
-import { validateOptions } from '../lib/validateOptions'
-
-import { replaceSerializer } from './utils/replaceSerializer'
const mockLilConfig = (result) => {
lilconfig.mockImplementationOnce(() => ({
@@ -76,7 +70,7 @@ describe('lintStaged', () => {
})
it('should use use the console if no logger is passed', async () => {
- expect.assertions(2)
+ expect.assertions(1)
mockLilConfig({ config: {} })
@@ -84,276 +78,13 @@ describe('lintStaged', () => {
const mockedConsole = makeConsoleMock()
console = mockedConsole
- await expect(lintStaged()).rejects.toMatchInlineSnapshot(
- `[Error: Configuration should not be empty]`
- )
-
- expect(mockedConsole.printHistory()).toMatchInlineSnapshot(`""`)
-
- console = previousConsole
- })
-
- it('should output config in debug mode', async () => {
- expect.assertions(1)
-
- const config = { '*': 'mytask' }
- mockLilConfig({ config })
-
- await lintStaged({ debug: true, quiet: true }, logger)
-
- expect(logger.printHistory()).toMatchInlineSnapshot(`
- "
- LOG Running lint-staged with the following config:
- LOG {
- '*': 'mytask'
- }"
- `)
- })
-
- it('should not output config in normal mode', async () => {
- expect.assertions(1)
-
- const config = { '*': 'mytask' }
- mockLilConfig({ config })
-
- await lintStaged({ quiet: true }, logger)
-
- expect(logger.printHistory()).toMatchInlineSnapshot(`""`)
- })
-
- it('should throw when invalid options are provided', async () => {
- expect.assertions(2)
-
- validateOptions.mockImplementationOnce(async () => {
- throw InvalidOptionsError
- })
-
- await expect(lintStaged({ '*': 'mytask' }, logger)).rejects.toMatchInlineSnapshot(
- `[Error: Invalid Options]`
- )
-
- expect(logger.printHistory()).toMatchInlineSnapshot(`""`)
- })
-
- it('should throw when invalid config is provided', async () => {
- const config = {}
- mockLilConfig({ config })
-
- await expect(lintStaged({ quiet: true }, logger)).rejects.toMatchInlineSnapshot(
- `[Error: Configuration should not be empty]`
- )
-
- expect(logger.printHistory()).toMatchInlineSnapshot(`""`)
- })
-
- it('should load JSON config file', async () => {
- expect.assertions(1)
-
- await lintStaged(
- {
- configPath: path.join(__dirname, '__mocks__', 'my-config.json'),
- debug: true,
- quiet: true,
- },
- logger
- )
-
- expect(logger.printHistory()).toMatchInlineSnapshot(`
- "
- LOG Running lint-staged with the following config:
- LOG {
- '*': 'mytask'
- }"
- `)
- })
-
- it('should load YAML config file', async () => {
- expect.assertions(1)
-
- await lintStaged(
- {
- configPath: path.join(__dirname, '__mocks__', 'my-config.yml'),
- debug: true,
- quiet: true,
- },
- logger
- )
-
- expect(logger.printHistory()).toMatchInlineSnapshot(`
- "
- LOG Running lint-staged with the following config:
- LOG {
- '*': 'mytask'
- }"
- `)
- })
-
- it('should load CommonJS config file from absolute path', async () => {
- expect.assertions(1)
-
- await lintStaged(
- {
- configPath: path.join(__dirname, '__mocks__', 'advanced-config.js'),
- debug: true,
- quiet: true,
- },
- logger
- )
-
- expect(logger.printHistory()).toMatchInlineSnapshot(`
- "
- LOG Running lint-staged with the following config:
- LOG {
- '*.css': [Function: *.css],
- '*.js': [Function: *.js]
- }"
- `)
- })
-
- it('should load CommonJS config file from relative path', async () => {
- expect.assertions(1)
-
- await lintStaged(
- {
- configPath: path.join('test', '__mocks__', 'advanced-config.js'),
- debug: true,
- quiet: true,
- },
- logger
- )
-
- expect(logger.printHistory()).toMatchInlineSnapshot(`
- "
- LOG Running lint-staged with the following config:
- LOG {
- '*.css': [Function: *.css],
- '*.js': [Function: *.js]
- }"
- `)
- })
-
- it('should load CommonJS config file from .cjs file', async () => {
- expect.assertions(1)
-
- await lintStaged(
- {
- configPath: path.join('test', '__mocks__', 'my-config.cjs'),
- debug: true,
- quiet: true,
- },
- logger
- )
-
- expect(logger.printHistory()).toMatchInlineSnapshot(`
- "
- LOG Running lint-staged with the following config:
- LOG {
- '*': 'mytask'
- }"
- `)
- })
-
- it('should load EMS config file from .mjs file', async () => {
- expect.assertions(1)
-
- await lintStaged(
- {
- configPath: path.join('test', '__mocks__', 'esm-config.mjs'),
- debug: true,
- quiet: true,
- },
- logger
- )
+ await lintStaged()
- expect(logger.printHistory()).toMatchInlineSnapshot(`
+ expect(mockedConsole.printHistory()).toMatchInlineSnapshot(`
"
- LOG Running lint-staged with the following config:
- LOG {
- '*': 'mytask'
- }"
- `)
- })
-
- it('should load EMS config file from .js file', async () => {
- expect.assertions(1)
-
- await lintStaged(
- {
- configPath: path.join('test', '__mocks__', 'esm-config-in-js.js'),
- debug: true,
- quiet: true,
- },
- logger
- )
-
- expect(logger.printHistory()).toMatchInlineSnapshot(`
- "
- LOG Running lint-staged with the following config:
- LOG {
- '*': 'mytask'
- }"
- `)
- })
-
- it('should use config object', async () => {
- expect.assertions(1)
-
- const config = { '*': 'node -e "process.exit(1)"' }
-
- await lintStaged({ config, quiet: true }, logger)
-
- expect(logger.printHistory()).toMatchInlineSnapshot(`""`)
- })
-
- it('should load a CJS module when specified', async () => {
- expect.assertions(1)
-
- jest.mock('my-lint-staged-config')
-
- await lintStaged({ configPath: 'my-lint-staged-config', quiet: true, debug: true }, logger)
-
- expect(logger.printHistory()).toMatchInlineSnapshot(`
- "
- LOG Running lint-staged with the following config:
- LOG {
- '*': 'mytask'
- }"
- `)
- })
-
- it('should print helpful error message when config file is not found', async () => {
- expect.assertions(2)
-
- mockLilConfig(null)
-
- await expect(lintStaged({ quiet: true }, logger)).rejects.toMatchInlineSnapshot(
- `[Error: Configuration could not be found]`
- )
-
- expect(logger.printHistory()).toMatchInlineSnapshot(`
- "
- ERROR Configuration could not be found."
+ ERROR ✖ Failed to get staged files!"
`)
- })
-
- it('should print helpful error message when explicit config file is not found', async () => {
- expect.assertions(3)
-
- const nonExistentConfig = 'fake-config-file.yml'
- // Serialize Windows, Linux and MacOS paths consistently
- expect.addSnapshotSerializer(
- replaceSerializer(
- /ENOENT: no such file or directory, open '([^']+)'/,
- `ENOENT: no such file or directory, open '${nonExistentConfig}'`
- )
- )
-
- await expect(
- lintStaged({ configPath: nonExistentConfig, quiet: true }, logger)
- ).rejects.toThrowError()
-
- expect(logger.printHistory()).toMatch('ENOENT')
- expect(logger.printHistory()).toMatch('Configuration could not be found')
+ console = previousConsole
})
})
diff --git a/test/integration.test.js b/test/integration.test.js
index d044a3a13..5f2716f1a 100644
--- a/test/integration.test.js
+++ b/test/integration.test.js
@@ -7,6 +7,7 @@ import normalize from 'normalize-path'
jest.unmock('lilconfig')
jest.unmock('execa')
+
jest.mock('../lib/resolveConfig', () => ({
/** Unfortunately necessary due to non-ESM tests. */
resolveConfig: (configPath) => {
@@ -18,6 +19,11 @@ jest.mock('../lib/resolveConfig', () => ({
},
}))
+jest.mock('../lib/dynamicImport', () => ({
+ // 'pathToFileURL' is not supported with Jest + Babel
+ dynamicImport: jest.fn().mockImplementation(async (input) => require(input)),
+}))
+
import { execGit as execGitBase } from '../lib/execGit'
import lintStaged from '../lib/index'
@@ -27,6 +33,30 @@ import { isWindowsActions, normalizeWindowsNewlines } from './utils/crossPlatfor
jest.setTimeout(20000)
+// Replace path like `../../git/lint-staged` with `/lint-staged`
+const replaceConfigPathSerializer = replaceSerializer(
+ /((?:\.\.\/)+).*\/lint-staged/gm,
+ `/lint-staged`
+)
+
+// Hide filepath from test snapshot because it's not important and varies in CI
+const replaceFilepathSerializer = replaceSerializer(
+ /prettier --write (.*)?$/gm,
+ `prettier --write `
+)
+
+// Awkwardly merge three serializers
+expect.addSnapshotSerializer({
+ test: (val) =>
+ ansiSerializer.test(val) ||
+ replaceConfigPathSerializer.test(val) ||
+ replaceFilepathSerializer.test(val),
+ print: (val, serialize) =>
+ replaceFilepathSerializer.print(
+ replaceConfigPathSerializer.print(ansiSerializer.print(val, serialize))
+ ),
+})
+
const testJsFilePretty = `module.exports = {
foo: "bar",
};
@@ -46,19 +76,28 @@ const fixJsConfig = { config: { '*.js': 'prettier --write' } }
let tmpDir
let cwd
+const ensureDir = async (inputPath) => fs.ensureDir(path.dirname(inputPath))
+
// Get file content, coercing Windows `\r\n` newlines to `\n`
const readFile = async (filename, dir = cwd) => {
- const file = await fs.readFile(path.resolve(dir, filename), { encoding: 'utf-8' })
+ const filepath = path.isAbsolute(filename) ? filename : path.join(dir, filename)
+ const file = await fs.readFile(filepath, { encoding: 'utf-8' })
return normalizeWindowsNewlines(file)
}
// Append to file, creating if it doesn't exist
-const appendFile = async (filename, content, dir = cwd) =>
- fs.appendFile(path.resolve(dir, filename), content)
+const appendFile = async (filename, content, dir = cwd) => {
+ const filepath = path.isAbsolute(filename) ? filename : path.join(dir, filename)
+ await ensureDir(filepath)
+ await fs.appendFile(filepath, content)
+}
// Write (over) file, creating if it doesn't exist
-const writeFile = async (filename, content, dir = cwd) =>
- fs.writeFile(path.resolve(dir, filename), content)
+const writeFile = async (filename, content, dir = cwd) => {
+ const filepath = path.isAbsolute(filename) ? filename : path.join(dir, filename)
+ await ensureDir(filepath)
+ fs.writeFile(filepath, content)
+}
// Wrap execGit to always pass `gitOps`
const execGit = async (args, options = {}) => execGitBase(args, { cwd, ...options })
@@ -258,10 +297,12 @@ describe('lint-staged', () => {
LOG [STARTED] Hiding unstaged changes to partially staged files...
LOG [SUCCESS] Hiding unstaged changes to partially staged files...
LOG [STARTED] Running tasks...
- LOG [STARTED] Running tasks for *.js
+ LOG [STARTED] /lint-staged — 1 file
+ LOG [STARTED] *.js — 1 file
LOG [STARTED] prettier --list-different
LOG [SUCCESS] prettier --list-different
- LOG [SUCCESS] Running tasks for *.js
+ LOG [SUCCESS] *.js — 1 file
+ LOG [SUCCESS] /lint-staged — 1 file
LOG [SUCCESS] Running tasks...
LOG [STARTED] Applying modifications...
LOG [SUCCESS] Applying modifications...
@@ -749,10 +790,12 @@ describe('lint-staged', () => {
LOG [STARTED] Preparing...
LOG [SUCCESS] Preparing...
LOG [STARTED] Running tasks...
- LOG [STARTED] Running tasks for *.js
+ LOG [STARTED] /lint-staged — 1 file
+ LOG [STARTED] *.js — 1 file
LOG [STARTED] git stash drop
LOG [SUCCESS] git stash drop
- LOG [SUCCESS] Running tasks for *.js
+ LOG [SUCCESS] *.js — 1 file
+ LOG [SUCCESS] /lint-staged — 1 file
LOG [SUCCESS] Running tasks...
LOG [STARTED] Applying modifications...
LOG [SUCCESS] Applying modifications...
@@ -787,12 +830,14 @@ describe('lint-staged', () => {
LOG [STARTED] Preparing...
LOG [SUCCESS] Preparing...
LOG [STARTED] Running tasks...
- LOG [STARTED] Running tasks for *.js
+ LOG [STARTED] /lint-staged — 1 file
+ LOG [STARTED] *.js — 1 file
LOG [STARTED] prettier --write
LOG [SUCCESS] prettier --write
LOG [STARTED] git add
LOG [SUCCESS] git add
- LOG [SUCCESS] Running tasks for *.js
+ LOG [SUCCESS] *.js — 1 file
+ LOG [SUCCESS] /lint-staged — 1 file
LOG [SUCCESS] Running tasks...
LOG [STARTED] Applying modifications...
ERROR [FAILED] Prevented an empty git commit!
@@ -935,10 +980,12 @@ describe('lint-staged', () => {
LOG [STARTED] Preparing...
LOG [SUCCESS] Preparing...
LOG [STARTED] Running tasks...
- LOG [STARTED] Running tasks for *.js
+ LOG [STARTED] /lint-staged — 1 file
+ LOG [STARTED] *.js — 1 file
LOG [STARTED] prettier --write
LOG [SUCCESS] prettier --write
- LOG [SUCCESS] Running tasks for *.js
+ LOG [SUCCESS] *.js — 1 file
+ LOG [SUCCESS] /lint-staged — 1 file
LOG [SUCCESS] Running tasks...
LOG [STARTED] Applying modifications...
LOG [SUCCESS] Applying modifications..."
@@ -969,19 +1016,6 @@ describe('lint-staged', () => {
})
).rejects.toThrowError()
- // Hide filepath from test snapshot because it's not important and varies in CI
- const replaceFilepathSerializer = replaceSerializer(
- /prettier --write (.*)?$/gm,
- `prettier --write FILEPATH`
- )
-
- // Awkwardly merge two serializers
- expect.addSnapshotSerializer({
- test: (val) => ansiSerializer.test(val) || replaceFilepathSerializer.test(val),
- print: (val, serialize) =>
- replaceFilepathSerializer.print(ansiSerializer.print(val, serialize)),
- })
-
expect(console.printHistory()).toMatchInlineSnapshot(`
"
WARN ⚠ Skipping backup because \`--no-stash\` was used.
@@ -991,10 +1025,12 @@ describe('lint-staged', () => {
LOG [STARTED] Hiding unstaged changes to partially staged files...
LOG [SUCCESS] Hiding unstaged changes to partially staged files...
LOG [STARTED] Running tasks...
- LOG [STARTED] Running tasks for *.js
- LOG [STARTED] prettier --write FILEPATH
- LOG [SUCCESS] prettier --write FILEPATH
- LOG [SUCCESS] Running tasks for *.js
+ LOG [STARTED] /lint-staged — 1 file
+ LOG [STARTED] *.js — 1 file
+ LOG [STARTED] prettier --write
+ LOG [SUCCESS] prettier --write
+ LOG [SUCCESS] *.js — 1 file
+ LOG [SUCCESS] /lint-staged — 1 file
LOG [SUCCESS] Running tasks...
LOG [STARTED] Applying modifications...
LOG [SUCCESS] Applying modifications...
@@ -1074,6 +1110,43 @@ describe('lint-staged', () => {
expect(await readFile('test.js')).toEqual(testJsFilePretty)
expect(await readFile('test2.js')).toEqual(testJsFilePretty)
})
+
+ it('should support multiple configuration files', async () => {
+ // Add some empty files
+ await writeFile('file.js', '')
+ await writeFile('deeper/file.js', '')
+ await writeFile('deeper/even/file.js', '')
+ await writeFile('deeper/even/deeper/file.js', '')
+ await writeFile('a/very/deep/file/path/file.js', '')
+
+ const echoJSConfig = (echo) =>
+ `module.exports = { '*.js': (files) => files.map((f) => \`echo ${echo} > \${f}\`) }`
+
+ await writeFile('.lintstagedrc.js', echoJSConfig('level-0'))
+ await writeFile('deeper/.lintstagedrc.js', echoJSConfig('level-1'))
+ await writeFile('deeper/even/.lintstagedrc.cjs', echoJSConfig('level-2'))
+
+ // Stage all files
+ await execGit(['add', '.'])
+
+ // Run lint-staged with `--shell` so that tasks do their thing
+ await gitCommit({ shell: true })
+
+ // 'file.js' matched '.lintstagedrc.json'
+ expect(await readFile('file.js')).toMatch('level-0')
+
+ // 'deeper/file.js' matched 'deeper/.lintstagedrc.json'
+ expect(await readFile('deeper/file.js')).toMatch('level-1')
+
+ // 'deeper/even/file.js' matched 'deeper/even/.lintstagedrc.json'
+ expect(await readFile('deeper/even/file.js')).toMatch('level-2')
+
+ // 'deeper/even/deeper/file.js' matched from parent 'deeper/even/.lintstagedrc.json'
+ expect(await readFile('deeper/even/deeper/file.js')).toMatch('level-2')
+
+ // 'a/very/deep/file/path/file.js' matched '.lintstagedrc.json'
+ expect(await readFile('a/very/deep/file/path/file.js')).toMatch('level-0')
+ })
})
describe('lintStaged', () => {
@@ -1106,10 +1179,12 @@ describe('lintStaged', () => {
LOG [STARTED] Preparing...
LOG [SUCCESS] Preparing...
LOG [STARTED] Running tasks...
- LOG [STARTED] Running tasks for *.js
+ LOG [STARTED] /lint-staged — 1 file
+ LOG [STARTED] *.js — 1 file
LOG [STARTED] prettier --list-different
LOG [SUCCESS] prettier --list-different
- LOG [SUCCESS] Running tasks for *.js
+ LOG [SUCCESS] *.js — 1 file
+ LOG [SUCCESS] /lint-staged — 1 file
LOG [SUCCESS] Running tasks...
LOG [STARTED] Applying modifications...
LOG [SUCCESS] Applying modifications..."
diff --git a/test/loadConfig.spec.js b/test/loadConfig.spec.js
index 2123012ae..2a533a19c 100644
--- a/test/loadConfig.spec.js
+++ b/test/loadConfig.spec.js
@@ -1,3 +1,9 @@
+import path from 'path'
+
+import makeConsoleMock from 'consolemock'
+
+import { loadConfig } from '../lib/loadConfig'
+
jest.mock('../lib/resolveConfig', () => ({
/** Unfortunately necessary due to non-ESM tests. */
resolveConfig: (configPath) => {
@@ -9,10 +15,170 @@ jest.mock('../lib/resolveConfig', () => ({
},
}))
-import { dynamicImport } from '../lib/loadConfig.js'
+jest.unmock('execa')
+
+/**
+ * This converts paths into `file://` urls, but this doesn't
+ * work with `import()` when using babel + jest.
+ */
+jest.mock('url', () => ({
+ pathToFileURL: (path) => path,
+}))
+
+// TODO: Never run tests in the project's WC because this might change source files git status
+
+describe('loadConfig', () => {
+ const logger = makeConsoleMock()
+
+ beforeEach(() => {
+ logger.clearHistory()
+ })
+
+ it('should load JSON config file', async () => {
+ expect.assertions(1)
+
+ const { config } = await loadConfig(
+ { configPath: path.join(__dirname, '__mocks__', 'my-config.json') },
+ logger
+ )
+
+ expect(config).toMatchInlineSnapshot(`
+ Object {
+ "*": "mytask",
+ }
+ `)
+ })
+
+ it('should load YAML config file', async () => {
+ expect.assertions(1)
+
+ const { config } = await loadConfig(
+ { configPath: path.join(__dirname, '__mocks__', 'my-config.yml') },
+ logger
+ )
+
+ expect(config).toMatchInlineSnapshot(`
+ Object {
+ "*": "mytask",
+ }
+ `)
+ })
+
+ it('should load CommonJS config file from absolute path', async () => {
+ expect.assertions(1)
+
+ const { config } = await loadConfig(
+ { configPath: path.join(__dirname, '__mocks__', 'advanced-config.js') },
+ logger
+ )
+
+ expect(config).toMatchInlineSnapshot(`
+ Object {
+ "*.css": [Function],
+ "*.js": [Function],
+ }
+ `)
+ })
+
+ it('should load CommonJS config file from relative path', async () => {
+ expect.assertions(1)
+
+ const { config } = await loadConfig(
+ { configPath: path.join('test', '__mocks__', 'advanced-config.js') },
+ logger
+ )
+
+ expect(config).toMatchInlineSnapshot(`
+ Object {
+ "*.css": [Function],
+ "*.js": [Function],
+ }
+ `)
+ })
+
+ it('should load CommonJS config file from .cjs file', async () => {
+ expect.assertions(1)
+
+ const { config } = await loadConfig(
+ { configPath: path.join('test', '__mocks__', 'my-config.cjs') },
+ logger
+ )
+
+ expect(config).toMatchInlineSnapshot(`
+ Object {
+ "*": "mytask",
+ }
+ `)
+ })
+
+ it('should load EMS config file from .mjs file', async () => {
+ expect.assertions(1)
+
+ const { config } = await loadConfig(
+ {
+ configPath: path.join('test', '__mocks__', 'esm-config.mjs'),
+ debug: true,
+ quiet: true,
+ },
+ logger
+ )
+
+ expect(config).toMatchInlineSnapshot(`
+ Object {
+ "*": "mytask",
+ }
+ `)
+ })
+
+ it('should load EMS config file from .js file', async () => {
+ expect.assertions(1)
+
+ const { config } = await loadConfig(
+ {
+ configPath: path.join('test', '__mocks__', 'esm-config-in-js.js'),
+ debug: true,
+ quiet: true,
+ },
+ logger
+ )
+
+ expect(config).toMatchInlineSnapshot(`
+ Object {
+ "*": "mytask",
+ }
+ `)
+ })
+
+ it('should load a CJS module when specified', async () => {
+ expect.assertions(1)
+
+ jest.mock('my-lint-staged-config')
+
+ const { config } = await loadConfig(
+ { configPath: 'my-lint-staged-config', quiet: true, debug: true },
+ logger
+ )
+
+ expect(config).toMatchInlineSnapshot(`
+ Object {
+ "*": "mytask",
+ }
+ `)
+ })
+
+ it('should return empty object when config file is not found', async () => {
+ expect.assertions(1)
+
+ const result = await loadConfig({ cwd: '/' })
+
+ expect(result).toMatchInlineSnapshot(`Object {}`)
+ })
+
+ it('should return empty object when explicit config file is not found', async () => {
+ expect.assertions(1)
+
+ const result = await loadConfig({ configPath: 'fake-config-file.yml' }, logger)
-describe('dynamicImport', () => {
- it('should log errors into console', () => {
- expect(() => dynamicImport('not-found.js')).rejects.toThrowError(`Cannot find module`)
+ expect(result).toMatchInlineSnapshot(`Object {}`)
})
})
diff --git a/test/runAll.spec.js b/test/runAll.spec.js
index 32404eb85..6adfde7de 100644
--- a/test/runAll.spec.js
+++ b/test/runAll.spec.js
@@ -15,6 +15,17 @@ jest.mock('../lib/getStagedFiles')
jest.mock('../lib/gitWorkflow')
jest.mock('../lib/resolveGitRepo')
+jest.mock('../lib/resolveConfig', () => ({
+ /** Unfortunately necessary due to non-ESM tests. */
+ resolveConfig: (configPath) => {
+ try {
+ return require.resolve(configPath)
+ } catch {
+ return configPath
+ }
+ },
+}))
+
getStagedFiles.mockImplementation(async () => [])
resolveGitRepo.mockImplementation(async () => {
@@ -22,6 +33,8 @@ resolveGitRepo.mockImplementation(async () => {
return { gitConfigDir: normalize(path.resolve(cwd, '.git')), gitDir: normalize(cwd) }
})
+const configPath = '.lintstagedrc.json'
+
describe('runAll', () => {
const globalConsoleTemp = console
@@ -39,7 +52,38 @@ describe('runAll', () => {
it('should resolve the promise with no tasks', async () => {
expect.assertions(1)
- await expect(runAll({ config: {} })).resolves.toMatchInlineSnapshot(`
+ await expect(runAll({ configObject: {}, configPath })).resolves.toMatchInlineSnapshot(`
+ Object {
+ "errors": Set {},
+ "hasPartiallyStagedFiles": null,
+ "output": Array [
+ "→ No staged files found.",
+ ],
+ "quiet": false,
+ "shouldBackup": true,
+ }
+ `)
+ })
+
+ it('should throw when failed to find staged files', async () => {
+ expect.assertions(1)
+ getStagedFiles.mockImplementationOnce(async () => null)
+ await expect(
+ runAll({ configObject: {}, configPath })
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`)
+ })
+
+ it('should throw when failed to find staged files and quiet', async () => {
+ expect.assertions(1)
+ getStagedFiles.mockImplementationOnce(async () => null)
+ await expect(
+ runAll({ configObject: {}, configPath, quiet: true })
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`)
+ })
+
+ it('should print output when no staged files', async () => {
+ expect.assertions(1)
+ await expect(runAll({ configObject: {}, configPath })).resolves.toMatchInlineSnapshot(`
Object {
"errors": Set {},
"hasPartiallyStagedFiles": null,
@@ -54,7 +98,8 @@ describe('runAll', () => {
it('should not print output when no staged files and quiet', async () => {
expect.assertions(1)
- await expect(runAll({ config: {}, quiet: true })).resolves.toMatchInlineSnapshot(`
+ await expect(runAll({ configObject: {}, configPath, quiet: true })).resolves
+ .toMatchInlineSnapshot(`
Object {
"errors": Set {},
"hasPartiallyStagedFiles": null,
@@ -67,37 +112,39 @@ describe('runAll', () => {
it('should resolve the promise with no files', async () => {
expect.assertions(1)
- await runAll({ config: { '*.js': ['echo "sample"'] } })
+ await runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath })
expect(console.printHistory()).toMatchInlineSnapshot(`""`)
})
it('should use an injected logger', async () => {
expect.assertions(1)
const logger = makeConsoleMock()
- await runAll({ config: { '*.js': ['echo "sample"'] }, debug: true }, logger)
+ await runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath, debug: true }, logger)
expect(logger.printHistory()).toMatchInlineSnapshot(`""`)
})
it('should exit without output when no staged files match configured tasks and quiet', async () => {
expect.assertions(1)
getStagedFiles.mockImplementationOnce(async () => ['sample.js'])
- await runAll({ config: { '*.css': ['echo "sample"'] }, quiet: true })
+ await runAll({ configObject: { '*.css': ['echo "sample"'] }, configPath, quiet: true })
expect(console.printHistory()).toMatchInlineSnapshot(`""`)
})
it('should not skip tasks if there are files', async () => {
expect.assertions(1)
getStagedFiles.mockImplementationOnce(async () => ['sample.js'])
- await runAll({ config: { '*.js': ['echo "sample"'] } })
+ await runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath })
expect(console.printHistory()).toMatchInlineSnapshot(`
"
LOG [STARTED] Preparing...
LOG [SUCCESS] Preparing...
LOG [STARTED] Running tasks...
- LOG [STARTED] Running tasks for *.js
+ LOG [STARTED] — 1 file
+ LOG [STARTED] *.js — 1 file
LOG [STARTED] echo \\"sample\\"
LOG [SUCCESS] echo \\"sample\\"
- LOG [SUCCESS] Running tasks for *.js
+ LOG [SUCCESS] *.js — 1 file
+ LOG [SUCCESS] — 1 file
LOG [SUCCESS] Running tasks...
LOG [STARTED] Applying modifications...
LOG [SUCCESS] Applying modifications...
@@ -118,7 +165,7 @@ describe('runAll', () => {
}))
await expect(
- runAll({ config: { '*.js': ['echo "sample"'] } })
+ runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath })
).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`)
expect(console.printHistory()).toMatchInlineSnapshot(`
@@ -126,7 +173,7 @@ describe('runAll', () => {
LOG [STARTED] Preparing...
ERROR [FAILED] test
LOG [STARTED] Running tasks...
- INFO [SKIPPED] Skipped because of previous git error.
+ INFO [SKIPPED] Running tasks...
LOG [STARTED] Applying modifications...
INFO [SKIPPED]
[SKIPPED] ✖ lint-staged failed due to a git error.
@@ -150,7 +197,7 @@ describe('runAll', () => {
)
await expect(
- runAll({ config: { '*.js': ['echo "sample"'] } })
+ runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath })
).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`)
expect(console.printHistory()).toMatchInlineSnapshot(`
@@ -158,9 +205,11 @@ describe('runAll', () => {
LOG [STARTED] Preparing...
LOG [SUCCESS] Preparing...
LOG [STARTED] Running tasks...
- LOG [STARTED] Running tasks for *.js
+ LOG [STARTED] — 1 file
+ LOG [STARTED] *.js — 1 file
LOG [STARTED] echo \\"sample\\"
ERROR [FAILED] echo \\"sample\\" [1]
+ ERROR [FAILED] echo \\"sample\\" [1]
LOG [SUCCESS] Running tasks...
LOG [STARTED] Applying modifications...
INFO [SKIPPED] Skipped because of errors from tasks.
@@ -187,7 +236,7 @@ describe('runAll', () => {
)
await expect(
- runAll({ config: { '*.js': ['echo "sample"'] } })
+ runAll({ configObject: { '*.js': ['echo "sample"'] }, configPath })
).rejects.toThrowErrorMatchingInlineSnapshot(`"lint-staged failed"`)
expect(console.printHistory()).toMatchInlineSnapshot(`
@@ -195,9 +244,11 @@ describe('runAll', () => {
LOG [STARTED] Preparing...
LOG [SUCCESS] Preparing...
LOG [STARTED] Running tasks...
- LOG [STARTED] Running tasks for *.js
+ LOG [STARTED] — 1 file
+ LOG [STARTED] *.js — 1 file
LOG [STARTED] echo \\"sample\\"
ERROR [FAILED] echo \\"sample\\" [SIGINT]
+ ERROR [FAILED] echo \\"sample\\" [SIGINT]
LOG [SUCCESS] Running tasks...
LOG [STARTED] Applying modifications...
INFO [SKIPPED] Skipped because of errors from tasks.
@@ -226,7 +277,13 @@ describe('runAll', () => {
try {
// Run lint-staged in `innerCwd` with relative option
// This means the sample task will receive `foo.js`
- await runAll({ config: { '*.js': mockTask }, stash: false, relative: true, cwd: innerCwd })
+ await runAll({
+ configObject: { '*.js': mockTask },
+ configPath,
+ stash: false,
+ relative: true,
+ cwd: innerCwd,
+ })
} catch {} // eslint-disable-line no-empty
// task received relative `foo.js`
diff --git a/test/validateConfig.spec.js b/test/validateConfig.spec.js
index b828df61d..970bd5c97 100644
--- a/test/validateConfig.spec.js
+++ b/test/validateConfig.spec.js
@@ -2,6 +2,8 @@ import makeConsoleMock from 'consolemock'
import { validateConfig } from '../lib/validateConfig'
+const configPath = '.lintstagedrc.json'
+
describe('validateConfig', () => {
let logger
@@ -12,7 +14,7 @@ describe('validateConfig', () => {
it('should throw and should print validation errors for invalid config 1', () => {
const invalidConfig = 'test'
- expect(() => validateConfig(invalidConfig, logger)).toThrowErrorMatchingSnapshot()
+ expect(() => validateConfig(invalidConfig, configPath, logger)).toThrowErrorMatchingSnapshot()
})
it('should throw and should print validation errors for invalid config', () => {
@@ -20,13 +22,17 @@ describe('validateConfig', () => {
foo: false,
}
- expect(() => validateConfig(invalidConfig, logger)).toThrowErrorMatchingSnapshot()
+ expect(() => validateConfig(invalidConfig, configPath, logger)).toThrowErrorMatchingSnapshot()
+ })
+
+ it('should throw for empty config', () => {
+ expect(() => validateConfig({}, configPath, logger)).toThrowErrorMatchingSnapshot()
})
it('should wrap function config into object', () => {
const functionConfig = (stagedFiles) => [`eslint --fix ${stagedFiles}', 'git add`]
- expect(validateConfig(functionConfig, logger)).toEqual({
+ expect(validateConfig(functionConfig, configPath, logger)).toEqual({
'*': functionConfig,
})
expect(logger.printHistory()).toEqual('')
@@ -37,7 +43,7 @@ describe('validateConfig', () => {
'*.js': ['eslint --fix', 'git add'],
}
- expect(() => validateConfig(validSimpleConfig, logger)).not.toThrow()
+ expect(() => validateConfig(validSimpleConfig, configPath, logger)).not.toThrow()
expect(logger.printHistory()).toEqual('')
})
@@ -50,7 +56,7 @@ describe('validateConfig', () => {
'*.css': [(filenames) => filenames.map((filename) => `eslint --fix ${filename}`)],
}
- expect(() => validateConfig(functionTask, logger)).not.toThrow()
+ expect(() => validateConfig(functionTask, configPath, logger)).not.toThrow()
expect(logger.printHistory()).toEqual('')
})
@@ -68,7 +74,7 @@ describe('validateConfig', () => {
subTaskConcurrency: 10,
}
- expect(() => validateConfig(advancedConfig, logger)).toThrowErrorMatchingSnapshot()
+ expect(() => validateConfig(advancedConfig, configPath, logger)).toThrowErrorMatchingSnapshot()
expect(logger.printHistory()).toMatchSnapshot()
})
@@ -77,7 +83,7 @@ describe('validateConfig', () => {
concurrent: 'my command',
}
- expect(() => validateConfig(stillValidConfig, logger)).not.toThrow()
+ expect(() => validateConfig(stillValidConfig, configPath, logger)).not.toThrow()
expect(logger.printHistory()).toEqual('')
})
})