lint-staged 16.0.0 → 16.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MIGRATION.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  #### Updated Node.js version requirement
4
4
 
5
- The lowest supported Node.js version is `18.19.0` or `20.5.0`, following requirements of `execa@9`. Please upgrade your Node.js version.
5
+ For version `lint-staged@16.0.0` the lowest supported Node.js version is `20.19.0`, following requirements of `nano-spawn`. Please upgrade your Node.js version.
6
+
7
+ For version `lint-staged@16.1.0` this is lowered to `20.17.0`, again following `nano-spawn`.
6
8
 
7
9
  #### Removed validation for removed advanced configuration file options
8
10
 
package/README.md CHANGED
@@ -34,13 +34,28 @@ $ git commit
34
34
 
35
35
  </details>
36
36
 
37
+ ## Table of Contents
38
+
39
+ - [Why](#why)
40
+ - [Installation and setup](#installation-and-setup)
41
+ - [Changelog](#changelog)
42
+ - [Command line flags](#command-line-flags)
43
+ - [Configuration](#configuration)
44
+ - [Filtering files](#filtering-files)
45
+ - [What commands are supported?](#what-commands-are-supported)
46
+ - [Running multiple commands in a sequence](#running-multiple-commands-in-a-sequence)
47
+ - [Using JS configuration files](#using-js-configuration-files)
48
+ - [Reformatting the code](#reformatting-the-code)
49
+ - [Examples](#examples)
50
+ - [Frequently Asked Questions](#frequently-asked-questions)
51
+
37
52
  ## Why
38
53
 
39
54
  Code quality tasks like formatters and linters make more sense when run before committing your code. By doing so you can ensure no errors go into the repository and enforce code style. But running a task on a whole project can be slow, and opinionated tasks such as linting can sometimes produce irrelevant results. Ultimately you only want to check files that will be committed.
40
55
 
41
56
  This project contains a script that will run arbitrary shell tasks with a list of staged files as an argument, filtered by a specified glob pattern.
42
57
 
43
- ## Related blog posts and talks
58
+ ### Related blog posts and talks
44
59
 
45
60
  - [Introductory Medium post - Andrey Okonetchnikov, 2016](https://medium.com/@okonetchnikov/make-linting-great-again-f3890e1ad6b8#.8qepn2b5l)
46
61
  - [Running Jest Tests Before Each Git Commit - Ben McCormick, 2017](https://benmccormick.org/2017/02/26/running-jest-tests-before-each-git-commit/)
@@ -94,13 +109,12 @@ Options:
94
109
  -c, --config [path] path to configuration file, or - to read from stdin
95
110
  --cwd [path] run all tasks in specific directory, instead of the current
96
111
  -d, --debug print additional debug information (default: false)
97
- --diff [string] override the default "--staged" flag of "git diff" to get list of files.
98
- Implies "--no-stash".
99
- --diff-filter [string] override the default "--diff-filter=ACMR" flag of "git diff" to get list of
100
- files
112
+ --diff [string] override the default "--staged" flag of "git diff" to get list of files. Implies
113
+ "--no-stash".
114
+ --diff-filter [string] override the default "--diff-filter=ACMR" flag of "git diff" to get list of files
101
115
  --max-arg-length [number] maximum length of the command-line argument string (default: 0)
102
- --no-stash disable the backup stash, and do not revert in case of errors. Implies
103
- "--no-hide-partially-staged".
116
+ --no-revert do not revert to original state in case of errors.
117
+ --no-stash disable the backup stash. Implies "--no-revert".
104
118
  --no-hide-partially-staged disable hiding unstaged changes from partially staged files
105
119
  -q, --quiet disable lint-staged’s own console output (default: false)
106
120
  -r, --relative pass relative filepaths to tasks (default: false)
@@ -129,10 +143,11 @@ Any lost modifications can be restored from a git stash:
129
143
  - **`--diff`**: By default tasks are filtered against all files staged in git, generated from `git diff --staged`. This option allows you to override the `--staged` flag with arbitrary revisions. For example to get a list of changed files between two branches, use `--diff="branch1...branch2"`. You can also read more from about [git diff](https://git-scm.com/docs/git-diff) and [gitrevisions](https://git-scm.com/docs/gitrevisions). This option also implies `--no-stash`.
130
144
  - **`--diff-filter`**: By default only files that are _added_, _copied_, _modified_, or _renamed_ are included. Use this flag to override the default `ACMR` value with something else: _added_ (`A`), _copied_ (`C`), _deleted_ (`D`), _modified_ (`M`), _renamed_ (`R`), _type changed_ (`T`), _unmerged_ (`U`), _unknown_ (`X`), or _pairing broken_ (`B`). See also the `git diff` docs for [--diff-filter](https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---diff-filterACDMRTUXB82308203).
131
145
  - **`--max-arg-length`**: long commands (a lot of files) are automatically split into multiple chunks when it detects the current shell cannot handle them. Use this flag to override the maximum length of the generated command string.
132
- - **`--no-stash`**: By default a backup stash will be created before running the tasks, and all task modifications will be reverted in case of an error. This option will disable creating the stash, and instead leave all modifications in the index when aborting the commit. Can be re-enabled with `--stash`. This option also implies `--no-hide-partially-staged`.
133
- - **`--no-hide-partially-staged`**: By default, unstaged changes from partially staged files will be hidden. This option will disable this behavior and include all unstaged changes in partially staged files. Can be re-enabled with `--hide-partially-staged`
134
- - **`--quiet`**: Supress all CLI output, except from tasks.
146
+ - **`--no-stash`**: By default a backup stash will be created before running the tasks, and all task modifications will be reverted in case of an error. This option will disable creating the stash, and instead leave all modifications in the index when aborting the commit.
147
+ - **`--no-hide-partially-staged`**: By default, unstaged changes from partially staged files will be hidden and applied back after running tasks. This option will disable this behavior, causing those changes to also be committed.
148
+ - **`--quiet`**: Suppress all CLI output, except from tasks.
135
149
  - **`--relative`**: Pass filepaths relative to `process.cwd()` (where `lint-staged` runs) to tasks. Default is `false`.
150
+ - **`--no-revert`**: By default all task modifications will be reverted in case of an error. This option will disable the behavior, and apply task modifications to the index before aborting the commit.
136
151
  - **`--verbose`**: Show task output even when tasks succeed. By default only failed output is shown.
137
152
 
138
153
  ## Configuration
@@ -998,7 +1013,7 @@ ESLint v8.51.0 introduced [`--no-warn-ignored` CLI flag](https://eslint.org/docs
998
1013
 
999
1014
  When running `lint-staged` via Husky hooks, TypeScript may ignore `tsconfig.json`, leading to errors like:
1000
1015
 
1001
- > **TS17004:** Cannot use JSX unless the '--jsx' flag is provided.
1016
+ > **TS17004:** Cannot use JSX unless the '--jsx' flag is provided.
1002
1017
  > **TS1056:** Accessors are only available when targeting ECMAScript 5 and higher.
1003
1018
 
1004
1019
  See issue [#825](https://github.com/okonet/lint-staged/issues/825) for more details.
@@ -61,30 +61,26 @@ program.option(
61
61
  program.option('--max-arg-length [number]', 'maximum length of the command-line argument string', 0)
62
62
 
63
63
  /**
64
- * We don't want to show the `--stash` flag because it's on by default, and only show the
65
- * negatable flag `--no-stash` in stead. There seems to be a bug in Commander.js where
64
+ * We don't want to show the `--revert` flag because it's on by default, and only show the
65
+ * negatable flag `--no-rever` instead. There seems to be a bug in Commander.js where
66
66
  * configuring only the latter won't actually set the default value.
67
67
  */
68
68
  program
69
69
  .addOption(
70
- new Option('--stash', 'enable the backup stash, and revert in case of errors')
71
- .default(true)
72
- .hideHelp()
70
+ new Option('--revert', 'revert to original state in case of errors').default(true).hideHelp()
73
71
  )
74
72
  .addOption(
75
- new Option(
76
- '--no-stash',
77
- 'disable the backup stash, and do not revert in case of errors. Implies "--no-hide-partially-staged".'
78
- )
73
+ new Option('--no-revert', 'do not revert to original state in case of errors.').default(false)
74
+ )
75
+
76
+ program
77
+ .addOption(new Option('--stash', 'enable the backup stash').default(true).hideHelp())
78
+ .addOption(
79
+ new Option('--no-stash', 'disable the backup stash. Implies "--no-revert".')
79
80
  .default(false)
80
- .implies({ hidePartiallyStaged: false })
81
+ .implies({ revert: false })
81
82
  )
82
83
 
83
- /**
84
- * We don't want to show the `--hide-partially-staged` flag because it's on by default, and only show the
85
- * negatable flag `--no-hide-partially-staged` in stead. There seems to be a bug in Commander.js where
86
- * configuring only the latter won't actually set the default value.
87
- */
88
84
  program
89
85
  .addOption(
90
86
  new Option('--hide-partially-staged', 'hide unstaged changes from partially staged files')
@@ -127,6 +123,7 @@ const options = {
127
123
  maxArgLength: cliOptions.maxArgLength || undefined,
128
124
  quiet: !!cliOptions.quiet,
129
125
  relative: !!cliOptions.relative,
126
+ revert: !!cliOptions.revert, // commander inverts `no-<x>` flags to `!x`
130
127
  stash: !!cliOptions.stash, // commander inverts `no-<x>` flags to `!x`
131
128
  hidePartiallyStaged: !!cliOptions.hidePartiallyStaged, // commander inverts `no-<x>` flags to `!x`
132
129
  verbose: !!cliOptions.verbose,
package/lib/chunkFiles.js CHANGED
@@ -10,7 +10,7 @@ const debugLog = debug('lint-staged:chunkFiles')
10
10
  * Chunk array into sub-arrays
11
11
  * @param {Array} arr
12
12
  * @param {Number} chunkCount
13
- * @retuns {Array<Array>}
13
+ * @returns {Array<Array>}
14
14
  */
15
15
  const chunkArray = (arr, chunkCount) => {
16
16
  if (chunkCount === 1) return [arr]
@@ -27,24 +27,32 @@ const chunkArray = (arr, chunkCount) => {
27
27
 
28
28
  /**
29
29
  * Chunk files into sub-arrays based on the length of the resulting argument string
30
+ *
31
+ * @typedef {import('./getStagedFiles.js').StagedFile[]} StagedFile
32
+ *
30
33
  * @param {Object} opts
31
- * @param {Array<String>} opts.files
34
+ * @param {Array<StagedFile>} opts.files
32
35
  * @param {String} [opts.baseDir] The optional base directory to resolve relative paths.
33
36
  * @param {number} [opts.maxArgLength] the maximum argument string length
34
37
  * @param {Boolean} [opts.relative] whether files are relative to `topLevelDir` or should be resolved as absolute
35
- * @returns {Array<Array<String>>}
38
+ * @returns {Array<Array<StagedFile>>}
36
39
  */
37
40
  export const chunkFiles = ({ files, baseDir, maxArgLength = null, relative = false }) => {
38
- const normalizedFiles = files.map((file) =>
39
- normalizePath(relative || !baseDir ? file : path.resolve(baseDir, file))
40
- )
41
+ const normalizedFiles = files.map((file) => {
42
+ return {
43
+ filepath: normalizePath(
44
+ relative || !baseDir ? file.filepath : path.resolve(baseDir, file.filepath)
45
+ ),
46
+ status: file.status,
47
+ }
48
+ })
41
49
 
42
50
  if (!maxArgLength) {
43
51
  debugLog('Skip chunking files because of undefined maxArgLength')
44
52
  return [normalizedFiles] // wrap in an array to return a single chunk
45
53
  }
46
54
 
47
- const fileListLength = normalizedFiles.join(' ').length
55
+ const fileListLength = normalizedFiles.map((file) => file.filepath).join(' ').length
48
56
  debugLog(
49
57
  `Resolved an argument string length of ${fileListLength} characters from ${normalizedFiles.length} files`
50
58
  )
@@ -13,13 +13,17 @@ const debugLog = debug('lint-staged:generateTasks')
13
13
  * @param {object} options
14
14
  * @param {Object} [options.config] - Task configuration
15
15
  * @param {Object} [options.cwd] - Current working directory
16
- * @param {boolean} [options.files] - Staged filepaths
16
+ * @param {import('./getStagedFiles.js').StagedFile[]} [options.files] - Staged filepaths
17
17
  * @param {boolean} [options.relative] - Whether filepaths to should be relative to cwd
18
18
  */
19
19
  export const generateTasks = ({ config, cwd = process.cwd(), files, relative = false }) => {
20
20
  debugLog('Generating linter tasks')
21
21
 
22
- const relativeFiles = files.map((file) => normalizePath(path.relative(cwd, file)))
22
+ /** @type {StagedFile[]} */
23
+ const relativeFiles = files.map((file) => ({
24
+ filepath: normalizePath(path.relative(cwd, file.filepath)),
25
+ status: file.status,
26
+ }))
23
27
 
24
28
  return Object.entries(config).map(([pattern, commands]) => {
25
29
  const isParentDirPattern = pattern.startsWith('../')
@@ -28,21 +32,34 @@ export const generateTasks = ({ config, cwd = process.cwd(), files, relative = f
28
32
  // specifies that it concerns a parent directory.
29
33
  const filteredFiles = relativeFiles.filter((file) => {
30
34
  if (isParentDirPattern) return true
31
- return !file.startsWith('..') && !path.isAbsolute(file)
35
+ return !file.filepath.startsWith('..') && !path.isAbsolute(file.filepath)
32
36
  })
33
37
 
34
- const matches = micromatch(filteredFiles, pattern, {
35
- cwd,
36
- dot: true,
37
- // If the pattern doesn't look like a path, enable `matchBase` to
38
- // match against filenames in every directory. This makes `*.js`
39
- // match both `test.js` and `subdirectory/test.js`.
40
- matchBase: !pattern.includes('/'),
41
- posixSlashes: true,
42
- strictBrackets: true,
43
- })
38
+ const matches = micromatch(
39
+ filteredFiles.map((file) => file.filepath),
40
+ pattern,
41
+ {
42
+ cwd,
43
+ dot: true,
44
+ // If the pattern doesn't look like a path, enable `matchBase` to
45
+ // match against filenames in every directory. This makes `*.js`
46
+ // match both `test.js` and `subdirectory/test.js`.
47
+ matchBase: !pattern.includes('/'),
48
+ posixSlashes: true,
49
+ strictBrackets: true,
50
+ }
51
+ )
44
52
 
45
- const fileList = matches.map((file) => normalizePath(relative ? file : path.resolve(cwd, file)))
53
+ const fileList = filteredFiles.flatMap((file) =>
54
+ matches.includes(file.filepath)
55
+ ? [
56
+ {
57
+ filepath: normalizePath(relative ? file.filepath : path.resolve(cwd, file.filepath)),
58
+ status: file.status,
59
+ },
60
+ ]
61
+ : []
62
+ )
46
63
 
47
64
  const task = { pattern, commands, fileList }
48
65
  debugLog('Generated task: \n%O', task)
@@ -15,7 +15,7 @@ export const isFunctionTask = (commands) => typeof commands === 'object' && !Arr
15
15
  * Handles function configuration and pushes the tasks into the task array
16
16
  *
17
17
  * @param {object} command
18
- * @param {Array<string>} files
18
+ * @param {import('./getStagedFiles.js').StagedFile[]} files
19
19
  * @throws {Error} If the function configuration is not valid
20
20
  */
21
21
  export const getFunctionTask = async (command, files) => {
@@ -23,7 +23,7 @@ export const getFunctionTask = async (command, files) => {
23
23
 
24
24
  const task = async (ctx) => {
25
25
  try {
26
- await command.task(files)
26
+ await command.task(files.map((file) => file.filepath))
27
27
  } catch (e) {
28
28
  throw makeErr(command.title, e, ctx)
29
29
  }
@@ -116,7 +116,7 @@ export const makeErr = (command, error, ctx) => {
116
116
  * @param {string} [options.cwd]
117
117
  * @param {String} options.topLevelDir - Current git repo top-level path
118
118
  * @param {Boolean} options.isFn - Whether the linter task is a function
119
- * @param {Array<string>} options.files — Filepaths to run the linter task against
119
+ * @param {string[]} options.files — Filepaths to run the linter task against
120
120
  * @param {Boolean} [options.verbose] — Always show task verbose
121
121
  * @returns {() => Promise<Array<string>>}
122
122
  */
@@ -11,7 +11,7 @@ const debugLog = debug('lint-staged:getSpawnedTasks')
11
11
  * @param {object} options
12
12
  * @param {Array<string|Function>|string|Function} options.commands
13
13
  * @param {string} options.cwd
14
- * @param {Array<string>} options.files
14
+ * @param {import('./getStagedFiles.js').StagedFile[]} options.files
15
15
  * @param {string} options.topLevelDir
16
16
  * @param {Boolean} verbose
17
17
  */
@@ -21,12 +21,14 @@ export const getSpawnedTasks = async ({ commands, cwd, files, topLevelDir, verbo
21
21
 
22
22
  const commandArray = Array.isArray(commands) ? commands : [commands]
23
23
 
24
+ const filepaths = files.map((f) => f.filepath)
25
+
24
26
  for (const cmd of commandArray) {
25
27
  // command function may return array of commands that already include `stagedFiles`
26
28
  const isFn = typeof cmd === 'function'
27
29
 
28
30
  /** Pass copy of file list to prevent mutation by function from config file. */
29
- const resolved = isFn ? await cmd([...files]) : cmd
31
+ const resolved = isFn ? await cmd([...filepaths]) : cmd
30
32
 
31
33
  const resolvedArray = Array.isArray(resolved) ? resolved : [resolved] // Wrap non-array command as array
32
34
 
@@ -43,7 +45,7 @@ export const getSpawnedTasks = async ({ commands, cwd, files, topLevelDir, verbo
43
45
  )
44
46
  }
45
47
 
46
- const task = getSpawnedTask({ command, cwd, files, topLevelDir, isFn, verbose })
48
+ const task = getSpawnedTask({ command, cwd, files: filepaths, topLevelDir, isFn, verbose })
47
49
  cmdTasks.push({ title: command, command, task })
48
50
  }
49
51
  }
@@ -5,6 +5,16 @@ import { getDiffCommand } from './getDiffCommand.js'
5
5
  import { normalizePath } from './normalizePath.js'
6
6
  import { parseGitZOutput } from './parseGitZOutput.js'
7
7
 
8
+ /**
9
+ * @typedef {'A'|'C'|'D'|'M'|'R'|'T'|'U'|'X'} FileSatus
10
+ * @typedef { { filepath: string; status: FileSatus }} StagedFile
11
+ *
12
+ * @param {Object} args
13
+ * @param {string} [args.cwd]
14
+ * @param {string} [args.diff]
15
+ * @param {string} [args.diffFilter]
16
+ * @retuns {Promise<StagedFile[] | null>}
17
+ */
8
18
  export const getStagedFiles = async ({ cwd = process.cwd(), diff, diffFilter } = {}) => {
9
19
  try {
10
20
  /**
@@ -35,20 +45,40 @@ export const getStagedFiles = async ({ cwd = process.cwd(), diff, diffFilter } =
35
45
  .split('\u0000:')
36
46
  .map(parseGitZOutput)
37
47
  .flatMap(([info, src, dst]) => {
38
- const [, dstMode, , , ,] = info.split(' ')
48
+ const [, dstMode, , , statusWithScore] = info.split(' ')
39
49
 
40
50
  /**
41
- * Filter out submodule root directory. "160000" is the object mode for submodules.
42
- * @see https://github.com/git/git/blob/485f5f863615e670fd97ae40af744e14072cfe18/object.h#L114-L120
51
+ * Filter out submodules and symlinks
52
+ * @see https://github.com/git/git/blob/cb96e1697ad6e54d11fc920c95f82977f8e438f8/Documentation/git-fast-import.adoc?plain=1#L634-L646
43
53
  */
44
- if (dstMode === '160000') {
54
+ if (dstMode === '160000' || dstMode === '120000') {
45
55
  return []
46
56
  }
47
57
 
58
+ /**
59
+ * @example "M"
60
+ * @example "R86"
61
+ *
62
+ * - A: addition of a file
63
+ * - C: copy of a file into a new one
64
+ * - D: deletion of a file
65
+ * - M: modification of the contents or mode of a file
66
+ * - R: renaming of a file
67
+ * - T: change in the type of the file (regular file, symbolic link or submodule)
68
+ * - U: file is unmerged (you must complete the merge before it can be committed)
69
+ * - X: "unknown" change type (most probably a bug, please report it)
70
+ */
71
+ const status = statusWithScore[0]
72
+
48
73
  /** "dst" exists when moving files, otherwise it's undefined and only "src" exists */
49
74
  const filename = dst ?? src
50
75
 
51
- return [normalizePath(path.resolve(cwd, filename))]
76
+ return [
77
+ {
78
+ filepath: normalizePath(path.resolve(cwd, filename)),
79
+ status,
80
+ },
81
+ ]
52
82
  })
53
83
  } catch {
54
84
  return null
@@ -66,6 +66,10 @@ const handleError = (error, ctx, symbol) => {
66
66
  }
67
67
 
68
68
  export class GitWorkflow {
69
+ /**
70
+ * @param {Object} opts
71
+ * @param {import('./getStagedFiles.js').StagedFile[][]} opts.matchedFileChunks
72
+ */
69
73
  constructor({ allowEmpty, gitConfigDir, topLevelDir, matchedFileChunks, diff, diffFilter }) {
70
74
  this.execGit = (args, options = {}) => execGit(args, { ...options, cwd: topLevelDir })
71
75
  this.deletedFiles = []
@@ -74,6 +78,7 @@ export class GitWorkflow {
74
78
  this.diff = diff
75
79
  this.diffFilter = diffFilter
76
80
  this.allowEmpty = allowEmpty
81
+ /** @type {import('./getStagedFiles.js').StagedFile[][]} */
77
82
  this.matchedFileChunks = matchedFileChunks
78
83
 
79
84
  /**
@@ -185,7 +190,7 @@ export class GitWorkflow {
185
190
  const [index, workingTree] = line
186
191
  return index !== ' ' && workingTree !== ' ' && index !== '?' && workingTree !== '?'
187
192
  })
188
- .map((line) => line.substr(3)) // Remove first three letters (index, workingTree, and a whitespace)
193
+ .map((line) => line.slice(3)) // Remove first three letters (index, workingTree, and a whitespace)
189
194
  .filter(Boolean) // Filter empty string
190
195
  debugLog('Found partially staged files:', partiallyStaged)
191
196
  return partiallyStaged.length ? partiallyStaged : null
@@ -198,13 +203,14 @@ export class GitWorkflow {
198
203
  try {
199
204
  debugLog(task.title)
200
205
 
201
- // Get a list of files with bot staged and unstaged changes.
206
+ // Get a list of files with both staged and unstaged changes.
202
207
  // Unstaged changes to these files should be hidden before the tasks run.
203
208
  this.partiallyStagedFiles = await this.getPartiallyStagedFiles()
204
209
 
205
210
  if (this.partiallyStagedFiles) {
206
211
  ctx.hasPartiallyStagedFiles = true
207
212
  const unstagedPatch = this.getHiddenFilepath(PATCH_UNSTAGED)
213
+ ctx.unstagedPatch = unstagedPatch
208
214
  const files = processRenames(this.partiallyStagedFiles)
209
215
  await this.execGit(['diff', ...GIT_DIFF_ARGS, '--output', unstagedPatch, '--', ...files])
210
216
  } else {
@@ -273,7 +279,8 @@ export class GitWorkflow {
273
279
  // These additions per chunk are run "serially" to prevent race conditions.
274
280
  // Git add creates a lockfile in the repo causing concurrent operations to fail.
275
281
  for (const files of this.matchedFileChunks) {
276
- await this.execGit(['add', '--', ...files])
282
+ /** @todo Deleted files cannot be staged because they're... deleted */
283
+ await this.execGit(['add', '--', ...files.map((f) => f.filepath)])
277
284
  }
278
285
 
279
286
  debugLog('Done adding task modifications to index!')
@@ -4,10 +4,17 @@ import debug from 'debug'
4
4
 
5
5
  const debugLog = debug('lint-staged:groupFilesByConfig')
6
6
 
7
+ /**
8
+ * @typedef {import('./getStagedFiles.js').StagedFile} StagedFile
9
+ * @type {(args: { config: {[key: string]: { config: any; files: string[] }}; files: StagedFile[]; singleConfigMode?: boolean }) => Promise<{[key: string]: { config: any; files: StagedFile[] } }>
10
+ */
7
11
  export const groupFilesByConfig = async ({ configs, files, singleConfigMode }) => {
8
12
  debugLog('Grouping %d files by %d configurations', files.length, Object.keys(configs).length)
9
13
 
14
+ /** @type {Set<StagedFile>} */
10
15
  const filesSet = new Set(files)
16
+
17
+ /** @type {{[key: string]: { config: any; files: StagedFile[] } }} */
11
18
  const filesByConfig = {}
12
19
 
13
20
  /** Configs are sorted deepest first by `searchConfigs` */
@@ -22,7 +29,7 @@ export const groupFilesByConfig = async ({ configs, files, singleConfigMode }) =
22
29
 
23
30
  /** Check if file is inside directory of the configuration file */
24
31
  const isInsideDir = (file) => {
25
- const relative = path.relative(dir, file)
32
+ const relative = path.relative(dir, file.filepath)
26
33
  return relative && !relative.startsWith('..') && !path.isAbsolute(relative)
27
34
  }
28
35
 
package/lib/index.d.ts CHANGED
@@ -66,6 +66,11 @@ export type Options = {
66
66
  * @default false
67
67
  */
68
68
  relative?: boolean
69
+ /**
70
+ * Revert to original state in case of errors
71
+ * @default true
72
+ */
73
+ revert?: boolean
69
74
  /**
70
75
  * Enable the backup stash, and revert in case of errors.
71
76
  * @warn Disabling this also implies `hidePartiallyStaged: false`.
package/lib/index.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  NO_CONFIGURATION,
7
7
  PREVENTED_EMPTY_COMMIT,
8
8
  RESTORE_STASH_EXAMPLE,
9
+ UNSTAGED_CHANGES_BACKUP_STASH_LOCATION,
9
10
  } from './messages.js'
10
11
  import { printTaskOutput } from './printTaskOutput.js'
11
12
  import { runAll } from './runAll.js'
@@ -15,6 +16,7 @@ import {
15
16
  ConfigNotFoundError,
16
17
  GetBackupStashError,
17
18
  GitError,
19
+ RestoreUnstagedChangesError,
18
20
  } from './symbols.js'
19
21
  import { validateOptions } from './validateOptions.js'
20
22
  import { getVersion } from './version.js'
@@ -57,6 +59,7 @@ const getMaxArgLength = () => {
57
59
  * @param {number} [options.maxArgLength] - Maximum argument string length
58
60
  * @param {boolean} [options.quiet] - Disable lint-staged’s own console output
59
61
  * @param {boolean} [options.relative] - Pass relative filepaths to tasks
62
+ * @param {boolean} [options.revert] - revert to original state in case of errors
60
63
  * @param {boolean} [options.stash] - Enable the backup stash, and revert in case of errors
61
64
  * @param {boolean} [options.verbose] - Show task output even when tasks succeed; by default only failed output is shown
62
65
  * @param {Logger} [logger]
@@ -78,7 +81,9 @@ const lintStaged = async (
78
81
  relative = false,
79
82
  // Stashing should be disabled by default when the `diff` option is used
80
83
  stash = diff === undefined,
81
- hidePartiallyStaged = stash,
84
+ // Cannot revert to original state without stash
85
+ revert = stash,
86
+ hidePartiallyStaged = true,
82
87
  verbose = false,
83
88
  } = {},
84
89
  logger = console
@@ -110,6 +115,7 @@ const lintStaged = async (
110
115
  maxArgLength,
111
116
  quiet,
112
117
  relative,
118
+ revert,
113
119
  stash,
114
120
  hidePartiallyStaged,
115
121
  verbose,
@@ -134,6 +140,9 @@ const lintStaged = async (
134
140
  logger.error(NO_CONFIGURATION)
135
141
  } else if (ctx.errors.has(ApplyEmptyCommitError)) {
136
142
  logger.warn(PREVENTED_EMPTY_COMMIT)
143
+ } else if (ctx.errors.has(RestoreUnstagedChangesError)) {
144
+ logger.warn(UNSTAGED_CHANGES_BACKUP_STASH_LOCATION)
145
+ logger.warn(ctx.unstagedPatch)
137
146
  } else if (
138
147
  (ctx.errors.has(GitError) || cleanupSkipped(ctx)) &&
139
148
  !ctx.errors.has(GetBackupStashError)
package/lib/messages.js CHANGED
@@ -31,25 +31,15 @@ export const skippingBackup = (hasInitialCommit, diff) => {
31
31
  const reason =
32
32
  diff !== undefined
33
33
  ? '`--diff` was used'
34
- : hasInitialCommit
35
- ? '`--no-stash` was used'
36
- : 'there’s no initial commit yet'
34
+ : (hasInitialCommit ? '`--no-stash` was used' : 'there’s no initial commit yet') +
35
+ '. This might result in data loss'
37
36
 
38
37
  return chalk.yellow(`${warning} Skipping backup because ${reason}.\n`)
39
38
  }
40
39
 
41
- export const skippingHidePartiallyStaged = (stash, diff) => {
42
- const reason =
43
- diff !== undefined
44
- ? '`--diff` was used'
45
- : !stash
46
- ? '`--no-stash` was used'
47
- : '`--no-hide-partially-staged` was used'
48
-
49
- return chalk.yellow(
50
- `${warning} Skipping hiding unstaged changes from partially staged files because ${reason}.\n`
51
- )
52
- }
40
+ export const SKIPPING_HIDE_PARTIALLY_CHANGED = chalk.yellow(
41
+ `${warning} Skipping hiding unstaged changes from partially staged files because \`--no-hide-partially-staged\` was used.\n`
42
+ )
53
43
 
54
44
  export const DEPRECATED_GIT_ADD = chalk.yellow(
55
45
  `${warning} Some of your tasks use \`git add\` command. Please remove it from the config since all modifications made by tasks will be automatically added to the git commit index.
@@ -96,3 +86,5 @@ export const failedToParseConfig = (
96
86
  ${error}
97
87
 
98
88
  See https://github.com/okonet/lint-staged#configuration.`
89
+
90
+ export const UNSTAGED_CHANGES_BACKUP_STASH_LOCATION = `Unstaged changes have been kept back in a patch file:`
package/lib/runAll.js CHANGED
@@ -22,8 +22,8 @@ import {
22
22
  NO_TASKS,
23
23
  NOT_GIT_REPO,
24
24
  SKIPPED_GIT_ERROR,
25
+ SKIPPING_HIDE_PARTIALLY_CHANGED,
25
26
  skippingBackup,
26
- skippingHidePartiallyStaged,
27
27
  } from './messages.js'
28
28
  import { normalizePath } from './normalizePath.js'
29
29
  import { resolveGitRepo } from './resolveGitRepo.js'
@@ -42,7 +42,12 @@ import { ConfigNotFoundError, GetStagedFilesError, GitError, GitRepoError } from
42
42
 
43
43
  const debugLog = debug('lint-staged:runAll')
44
44
 
45
- const createError = (ctx) => Object.assign(new Error('lint-staged failed'), { ctx })
45
+ /**
46
+ * @param {ReturnType<typeof getInitialState>} ctx context
47
+ * @param {unknown} cause error cause
48
+ */
49
+ const createError = (ctx, cause) =>
50
+ Object.assign(new Error('lint-staged failed', { cause }), { ctx })
46
51
 
47
52
  /**
48
53
  * Executes all tasks and either resolves or rejects the promise
@@ -59,6 +64,7 @@ const createError = (ctx) => Object.assign(new Error('lint-staged failed'), { ct
59
64
  * @param {number} [options.maxArgLength] - Maximum argument string length
60
65
  * @param {boolean} [options.quiet] - Disable lint-staged’s own console output
61
66
  * @param {boolean} [options.relative] - Pass relative filepaths to tasks
67
+ * @param {boolean} [options.revert] - revert to original state in case of errors
62
68
  * @param {boolean} [options.stash] - Enable the backup stash, and revert in case of errors
63
69
  * @param {boolean} [options.verbose] - Show task output even when tasks succeed; by default only failed output is shown
64
70
  * @param {Logger} logger
@@ -79,7 +85,9 @@ export const runAll = async (
79
85
  relative = false,
80
86
  // Stashing should be disabled by default when the `diff` option is used
81
87
  stash = diff === undefined,
82
- hidePartiallyStaged = stash,
88
+ // Cannot revert to original state without stash
89
+ revert = stash,
90
+ hidePartiallyStaged = true,
83
91
  verbose = false,
84
92
  },
85
93
  logger = console
@@ -91,13 +99,13 @@ export const runAll = async (
91
99
  cwd = hasExplicitCwd ? path.resolve(cwd) : process.cwd()
92
100
  debugLog('Using working directory `%s`', cwd)
93
101
 
94
- const ctx = getInitialState({ quiet })
102
+ const ctx = getInitialState({ quiet, revert })
95
103
 
96
104
  const { topLevelDir, gitConfigDir } = await resolveGitRepo(cwd)
97
105
  if (!topLevelDir) {
98
106
  if (!quiet) ctx.output.push(NOT_GIT_REPO)
99
107
  ctx.errors.add(GitRepoError)
100
- throw createError(ctx)
108
+ throw createError(ctx, GitRepoError)
101
109
  }
102
110
 
103
111
  // Test whether we have any commits or not.
@@ -115,19 +123,19 @@ export const runAll = async (
115
123
 
116
124
  ctx.shouldHidePartiallyStaged = hidePartiallyStaged
117
125
  if (!ctx.shouldHidePartiallyStaged && !quiet) {
118
- logger.warn(skippingHidePartiallyStaged(hasInitialCommit && stash, diff))
126
+ logger.warn(SKIPPING_HIDE_PARTIALLY_CHANGED)
119
127
  }
120
128
 
121
- const files = await getStagedFiles({ cwd: topLevelDir, diff, diffFilter })
122
- if (!files) {
129
+ const stagedFiles = await getStagedFiles({ cwd: topLevelDir, diff, diffFilter })
130
+ if (!stagedFiles) {
123
131
  if (!quiet) ctx.output.push(FAILED_GET_STAGED_FILES)
124
132
  ctx.errors.add(GetStagedFilesError)
125
133
  throw createError(ctx, GetStagedFilesError)
126
134
  }
127
- debugLog('Loaded list of staged files in git:\n%O', files)
135
+ debugLog('Loaded list of staged files in git:\n%O', stagedFiles)
128
136
 
129
137
  // If there are no files avoid executing any lint-staged logic
130
- if (files.length === 0) {
138
+ if (stagedFiles.length === 0) {
131
139
  if (!quiet) ctx.output.push(NO_STAGED_FILES)
132
140
  return ctx
133
141
  }
@@ -143,7 +151,7 @@ export const runAll = async (
143
151
 
144
152
  const filesByConfig = await groupFilesByConfig({
145
153
  configs: foundConfigs,
146
- files,
154
+ files: stagedFiles,
147
155
  singleConfigMode: configObject || configPath !== undefined,
148
156
  })
149
157
 
@@ -171,6 +179,7 @@ export const runAll = async (
171
179
  const listrTasks = []
172
180
 
173
181
  // Set of all staged files that matched a task glob. Values in a set are unique.
182
+ /** @type {Set<import('./getStagedFiles.js').StagedFile>} */
174
183
  const matchedFiles = new Set()
175
184
 
176
185
  for (const [configPath, { config, files }] of Object.entries(filesByConfig)) {
@@ -192,7 +201,7 @@ export const runAll = async (
192
201
  const chunkListrTasks = await Promise.all(
193
202
  generateTasks({ config, cwd: groupCwd, files, relative }).map((task) =>
194
203
  (isFunctionTask(task.commands)
195
- ? getFunctionTask(task.commands, files)
204
+ ? getFunctionTask(task.commands, task.fileList)
196
205
  : getSpawnedTasks({
197
206
  commands: task.commands,
198
207
  cwd: groupCwd,
@@ -206,9 +215,12 @@ export const runAll = async (
206
215
  // Make sure relative files are normalized to the
207
216
  // group cwd, because other there might be identical
208
217
  // relative filenames in the entire set.
209
- const normalizedFile = path.isAbsolute(file)
218
+ const normalizedFile = path.isAbsolute(file.filepath)
210
219
  ? file
211
- : normalizePath(path.join(groupCwd, file))
220
+ : {
221
+ filepath: normalizePath(path.join(groupCwd, file.filepath)),
222
+ status: file.status,
223
+ }
212
224
 
213
225
  matchedFiles.add(normalizedFile)
214
226
  })
@@ -272,6 +284,7 @@ export const runAll = async (
272
284
  }
273
285
 
274
286
  // Chunk matched files for better Windows compatibility
287
+ /** @type {import('./getStagedFiles.js').StagedFile[][]} */
275
288
  const matchedFileChunks = chunkFiles({
276
289
  // matched files are relative to `cwd`, not `topLevelDir`, when `relative` is used
277
290
  baseDir: cwd,
@@ -289,6 +302,8 @@ export const runAll = async (
289
302
  diffFilter,
290
303
  })
291
304
 
305
+ logger.log('shouldHidePartiallyStagedFiles', shouldHidePartiallyStagedFiles(ctx))
306
+
292
307
  const runner = new Listr(
293
308
  [
294
309
  {
package/lib/state.js CHANGED
@@ -8,23 +8,25 @@ import {
8
8
  TaskError,
9
9
  } from './symbols.js'
10
10
 
11
- export const getInitialState = ({ quiet = false } = {}) => ({
12
- hasPartiallyStagedFiles: null,
13
- shouldBackup: null,
11
+ export const getInitialState = ({ quiet = false, revert = true } = {}) => ({
14
12
  backupHash: null,
15
- shouldHidePartiallyStaged: true,
16
13
  errors: new Set([]),
17
14
  events: new EventEmitter(),
15
+ hasPartiallyStagedFiles: null,
18
16
  output: [],
19
17
  quiet,
18
+ shouldBackup: null,
19
+ shouldHidePartiallyStaged: true,
20
+ shouldRevert: revert,
21
+ unstagedPatch: null,
20
22
  })
21
23
 
22
24
  export const shouldHidePartiallyStagedFiles = (ctx) =>
23
25
  ctx.hasPartiallyStagedFiles && ctx.shouldHidePartiallyStaged
24
26
 
25
27
  export const applyModificationsSkipped = (ctx) => {
26
- // Always apply back unstaged modifications when skipping backup
27
- if (!ctx.shouldBackup) return false
28
+ // Always apply back unstaged modifications when skipping revert or backup
29
+ if (!ctx.shouldRevert || !ctx.shouldBackup) return false
28
30
  // Should be skipped in case of git errors
29
31
  if (ctx.errors.has(GitError)) {
30
32
  return GIT_ERROR
@@ -48,7 +50,9 @@ export const restoreUnstagedChangesSkipped = (ctx) => {
48
50
  }
49
51
 
50
52
  export const restoreOriginalStateEnabled = (ctx) =>
51
- ctx.shouldBackup && (ctx.errors.has(TaskError) || ctx.errors.has(RestoreUnstagedChangesError))
53
+ !!ctx.shouldRevert &&
54
+ !!ctx.shouldBackup &&
55
+ (ctx.errors.has(TaskError) || ctx.errors.has(RestoreUnstagedChangesError))
52
56
 
53
57
  export const restoreOriginalStateSkipped = (ctx) => {
54
58
  // Should be skipped in case of unknown git errors
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lint-staged",
3
- "version": "16.0.0",
3
+ "version": "16.1.1",
4
4
  "description": "Lint files staged by git",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,7 +21,7 @@
21
21
  "url": "https://opencollective.com/lint-staged"
22
22
  },
23
23
  "engines": {
24
- "node": ">=20.18"
24
+ "node": ">=20.17"
25
25
  },
26
26
  "type": "module",
27
27
  "bin": {
@@ -48,36 +48,36 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "chalk": "^5.4.1",
51
- "commander": "^13.1.0",
52
- "debug": "^4.4.0",
51
+ "commander": "^14.0.0",
52
+ "debug": "^4.4.1",
53
53
  "lilconfig": "^3.1.3",
54
54
  "listr2": "^8.3.3",
55
55
  "micromatch": "^4.0.8",
56
- "nano-spawn": "^1.0.0",
56
+ "nano-spawn": "^1.0.2",
57
57
  "pidtree": "^0.6.0",
58
58
  "string-argv": "^0.3.2",
59
- "yaml": "^2.7.1"
59
+ "yaml": "^2.8.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@changesets/changelog-github": "0.5.1",
63
- "@changesets/cli": "2.29.3",
63
+ "@changesets/cli": "2.29.4",
64
64
  "@commitlint/cli": "19.8.1",
65
65
  "@commitlint/config-conventional": "19.8.1",
66
- "@eslint/js": "9.26.0",
66
+ "@eslint/js": "9.29.0",
67
67
  "consolemock": "1.1.0",
68
68
  "cross-env": "7.0.3",
69
- "eslint": "9.26.0",
69
+ "eslint": "9.29.0",
70
70
  "eslint-config-prettier": "10.1.5",
71
- "eslint-plugin-jest": "28.11.0",
72
- "eslint-plugin-n": "17.18.0",
73
- "eslint-plugin-prettier": "5.4.0",
71
+ "eslint-plugin-jest": "28.13.5",
72
+ "eslint-plugin-n": "17.20.0",
73
+ "eslint-plugin-prettier": "5.4.1",
74
74
  "eslint-plugin-simple-import-sort": "12.1.1",
75
75
  "husky": "9.1.7",
76
- "jest": "29.7.0",
76
+ "jest": "30.0.0",
77
77
  "jest-snapshot-serializer-ansi": "2.2.1",
78
78
  "mock-stdin": "1.0.0",
79
79
  "prettier": "3.5.3",
80
- "semver": "7.7.1",
80
+ "semver": "7.7.2",
81
81
  "typescript": "5.8.3"
82
82
  },
83
83
  "keywords": [