lint-staged 17.1.0 → 17.2.0

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/README.md CHANGED
@@ -1,9 +1,5 @@
1
1
  # 🚫💩 lint-staged
2
2
 
3
- [![npm version](https://badge.fury.io/js/lint-staged.svg)](https://badge.fury.io/js/lint-staged)
4
-
5
- ---
6
-
7
3
  Run tasks like formatters and linters against staged git files and don't let :poop: slip into your code base!
8
4
 
9
5
  ```bash
@@ -168,7 +164,7 @@ By default changes made by tasks are automatically staged and added to the commi
168
164
 
169
165
  #### `--max-arg-length [number]`
170
166
 
171
- 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.
167
+ The list of staged files are appended to configured tasks as arguments, and the resulting string might be too long for the current shell, especially on Windows. _Lint-staged_ tries to avoid this by splitting the list of staged files into chunks resulting in the commands being run multiple times. This behavior affects all tasks, including functions. Use the `--max-arg-length` to override the platform-specific default value if you want to avoid this. Setting `--max-arg-length=Infinity` will disable chunking completely.
172
168
 
173
169
  #### `--no-stash`
174
170
 
package/lib/cli.js CHANGED
@@ -91,7 +91,7 @@ const CLI_OPTIONS = [
91
91
  },
92
92
  {
93
93
  flag: 'max-arg-length',
94
- type: 'string', // Parsed with `parseInt()` below
94
+ type: 'string', // Parsed with `Number()` below
95
95
  positional: '[number]',
96
96
  description: 'maximum length of the command-line argument string (default: 0)',
97
97
  },
@@ -128,6 +128,20 @@ const CLI_OPTIONS = [
128
128
  },
129
129
  ]
130
130
 
131
+ /** @type {(value: string |undefined) => number | boolean | null | undefined} */
132
+ const parseBoolOrNumber = (value) => {
133
+ switch (value) {
134
+ case undefined:
135
+ return undefined
136
+ case 'true':
137
+ return true
138
+ case 'false':
139
+ return false
140
+ default:
141
+ return Number(value)
142
+ }
143
+ }
144
+
131
145
  /** @param {string[]} argv */
132
146
  export const parseCliOptions = (argv) => {
133
147
  const options = CLI_OPTIONS.reduce((acc, current) => {
@@ -167,18 +181,9 @@ export const parseCliOptions = (argv) => {
167
181
  values['hide-unstaged'] = false // becomes redundant
168
182
  }
169
183
 
170
- const maxArgLength =
171
- values['max-arg-length'] !== undefined ? parseInt(values['max-arg-length'], 10) : undefined
172
-
173
- if (Number.isNaN(maxArgLength)) {
174
- throw new TypeError(`Option '--mar-arg-length' takes a numeric argument`, {
175
- cause: { '--max-arg-length': values['max-arg-length'] },
176
- })
177
- }
178
-
179
184
  return {
180
185
  allowEmpty: values['allow-empty'] ?? false,
181
- concurrent: values.concurrent === undefined ? true : JSON.parse(values.concurrent),
186
+ concurrent: parseBoolOrNumber(values.concurrent) ?? true,
182
187
  configPath: values.config,
183
188
  continueOnError: !!values['continue-on-error'],
184
189
  cwd: values.cwd,
@@ -190,7 +195,7 @@ export const parseCliOptions = (argv) => {
190
195
  hidePartiallyStaged: values['hide-partially-staged'] ?? true,
191
196
  hideUnstaged: !!values['hide-unstaged'],
192
197
  hideAll: !!values['hide-all'],
193
- maxArgLength,
198
+ maxArgLength: parseBoolOrNumber(values['max-arg-length']),
194
199
  quiet: !!values.quiet,
195
200
  relative: !!values.relative,
196
201
  revert: values.revert ?? true,
@@ -16,7 +16,7 @@ const debugLog = createDebug('lint-staged:generateTasks')
16
16
  * @param {boolean} [options.relative] - Whether filepaths to should be relative to cwd
17
17
  */
18
18
  export const generateTasks = ({ config, cwd = process.cwd(), files, relative = false }) => {
19
- debugLog('Generating linter tasks')
19
+ debugLog('Generating tasks')
20
20
 
21
21
  /** @type {StagedFile[]} */
22
22
  const relativeFiles = files.map((file) => ({
@@ -57,16 +57,16 @@ export const createTaskError = (command, result, ctx, signal = 'FAILED') => {
57
57
  }
58
58
 
59
59
  /**
60
- * Returns the task function for the linter.
60
+ * Returns the spawnable task.
61
61
  *
62
62
  * @param {Object} options
63
63
  * @param {AbortController} options.abortController
64
- * @param {string} options.command — Linter task
64
+ * @param {string} options.command — the command to spawn
65
65
  * @param {string} [options.continueOnError]
66
66
  * @param {string} [options.cwd]
67
67
  * @param {String} options.topLevelDir - Current git repo top-level path
68
- * @param {Boolean} options.isFn - Whether the linter task is a function
69
- * @param {string[]} options.files — Filepaths to run the linter task against
68
+ * @param {Boolean} options.isFn - Whether the task is a function instead of a string
69
+ * @param {string[]} options.files — Filepaths to run the task against
70
70
  * @param {Boolean} [options.verbose] — Always show task verbose
71
71
  * @returns {() => Promise<Array<string>>}
72
72
  */
@@ -4,6 +4,104 @@ import { configurationError } from './messages.js'
4
4
 
5
5
  const debugLog = createDebug('lint-staged:getSpawnedTasks')
6
6
 
7
+ /**
8
+ * Get the maximum length of a command-line argument string based on current platform
9
+ *
10
+ * https://serverfault.com/questions/69430/what-is-the-maximum-length-of-a-command-line-in-mac-os-x
11
+ * https://support.microsoft.com/en-us/help/830473/command-prompt-cmd-exe-command-line-string-limitation
12
+ * https://unix.stackexchange.com/a/120652
13
+ */
14
+ export const getMaxArgLength = (platform = process.platform) => {
15
+ switch (platform) {
16
+ case 'darwin':
17
+ return 262_144
18
+ case 'win32':
19
+ return 8_191
20
+ default:
21
+ return 131_072
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Chunk `files` so that when joined with `command`, the resulting string fits into `maxArgLength`.
27
+ * For function commands, they are evaluated with the list of files and measured afterwards.
28
+ *
29
+ * @param {string | Function} command
30
+ * @param {string[]} files
31
+ * @param {number} [maxArgLength]
32
+ * @returns {Promise<Array<{ command: string, files: string[] }>>}
33
+ */
34
+ export const chunkFilesForCommand = async (command, files, maxArgLength) => {
35
+ const isFn = typeof command === 'function'
36
+ const resolved = isFn ? await command([...files]) : command
37
+ const resolvedCommands = Array.isArray(resolved) ? resolved : [resolved]
38
+
39
+ if (isFn && resolvedCommands.some((command) => typeof command !== 'string')) {
40
+ throw new Error(
41
+ configurationError(
42
+ '[Function]',
43
+ 'Function task should return a string or an array of strings',
44
+ resolved
45
+ )
46
+ )
47
+ }
48
+
49
+ if (!maxArgLength || maxArgLength === Infinity || files.length <= 1) {
50
+ debugLog(
51
+ 'Skipping chunking because maxArgLength is %s and there are %s files',
52
+ maxArgLength,
53
+ files.length
54
+ )
55
+
56
+ return resolvedCommands.map((resolvedCommand) => ({
57
+ command: resolvedCommand,
58
+ files,
59
+ }))
60
+ }
61
+
62
+ const fits = resolvedCommands.every((resolvedCommand) => {
63
+ /**
64
+ * Really the command is spawned using `tinyexec` and the files passed as an array of
65
+ * arguments; but this approximates the length without quoting filenames.
66
+ */
67
+ const finalCommandString = isFn ? resolvedCommand : resolvedCommand + ' ' + files.join(' ')
68
+
69
+ debugLog(
70
+ 'Resolved an argument string length of %d characters from %d files',
71
+ finalCommandString.length,
72
+ files.length
73
+ )
74
+
75
+ return finalCommandString.length <= maxArgLength
76
+ })
77
+
78
+ if (fits) {
79
+ return resolvedCommands.map((resolvedCommand) => ({
80
+ command: resolvedCommand,
81
+ files,
82
+ }))
83
+ }
84
+
85
+ const middle = Math.ceil(files.length / 2)
86
+ const left = files.slice(0, middle)
87
+ const right = files.slice(middle)
88
+
89
+ debugLog(
90
+ 'Splitting %d files into chunks of %d and %d for maxArgLength of %d',
91
+ files.length,
92
+ left.length,
93
+ right.length,
94
+ maxArgLength
95
+ )
96
+
97
+ const commands = await Promise.all([
98
+ chunkFilesForCommand(command, left, maxArgLength),
99
+ chunkFilesForCommand(command, right, maxArgLength),
100
+ ])
101
+
102
+ return commands.flat()
103
+ }
104
+
7
105
  /**
8
106
  * Creates and returns an array of tasks which map to the given commands.
9
107
  *
@@ -13,6 +111,7 @@ const debugLog = createDebug('lint-staged:getSpawnedTasks')
13
111
  * @param {string} options.continueOnError
14
112
  * @param {string} options.cwd
15
113
  * @param {import('./getStagedFiles.js').StagedFile[]} options.files
114
+ * @param {number} [options.maxArgLength]
16
115
  * @param {string} options.topLevelDir
17
116
  * @param {Boolean} verbose
18
117
  */
@@ -22,52 +121,41 @@ export const getSpawnedTasks = async ({
22
121
  continueOnError,
23
122
  cwd,
24
123
  files,
124
+ maxArgLength,
25
125
  topLevelDir,
26
126
  verbose,
27
127
  }) => {
28
128
  debugLog('Creating tasks for commands %o', commands)
29
- const cmdTasks = []
30
129
 
31
130
  const commandArray = Array.isArray(commands) ? commands : [commands]
32
131
 
33
132
  const filepaths = files.map((f) => f.filepath)
34
133
 
35
- for (const cmd of commandArray) {
36
- // command function may return array of commands that already include `stagedFiles`
37
- const isFn = typeof cmd === 'function'
38
-
39
- /** Pass copy of file list to prevent mutation by function from config file. */
40
- const resolved = isFn ? await cmd([...filepaths]) : cmd
41
-
42
- const resolvedArray = Array.isArray(resolved) ? resolved : [resolved] // Wrap non-array command as array
43
-
44
- for (const command of resolvedArray) {
45
- // If the function linter didn't return string | string[] it won't work
46
- // Do the validation here instead of `validateConfig` to skip evaluating the function multiple times
47
- if (isFn && typeof command !== 'string') {
48
- throw new Error(
49
- configurationError(
50
- '[Function]',
51
- 'Function task should return a string or an array of strings',
52
- resolved
53
- )
54
- )
55
- }
56
-
57
- const task = getSpawnedTask({
58
- abortController,
59
- command,
60
- continueOnError,
61
- cwd,
62
- files: filepaths,
63
- topLevelDir,
64
- isFn,
65
- verbose,
66
- })
67
-
68
- cmdTasks.push({ title: command, task })
69
- }
134
+ if (filepaths.length === 0) {
135
+ debugLog('Skipping task generation because no files matched', { commands })
136
+ return []
70
137
  }
71
138
 
72
- return cmdTasks
139
+ const spawnedTasks = await Promise.all(
140
+ commandArray.map(async (cmd) => {
141
+ const isFn = typeof cmd === 'function'
142
+ const chunkedCommands = await chunkFilesForCommand(cmd, filepaths, maxArgLength)
143
+
144
+ return chunkedCommands.map(({ command, files: chunkedFiles }) => ({
145
+ title: command,
146
+ task: getSpawnedTask({
147
+ abortController,
148
+ command,
149
+ continueOnError,
150
+ cwd,
151
+ files: chunkedFiles,
152
+ topLevelDir,
153
+ isFn,
154
+ verbose,
155
+ }),
156
+ }))
157
+ })
158
+ )
159
+
160
+ return spawnedTasks.flat()
73
161
  }
@@ -8,6 +8,7 @@ import { execGit } from './execGit.js'
8
8
  import * as figures from './figures.js'
9
9
  import { readFile, unlink, writeFile } from './file.js'
10
10
  import { getDiffCommand } from './getDiffCommand.js'
11
+ import { chunkFilesForCommand } from './getSpawnedTasks.js'
11
12
  import { normalizePath } from './normalizePath.js'
12
13
  import { parseGitZOutput } from './parseGitZOutput.js'
13
14
  import { runParallelTasks } from './runParallelTasks.js'
@@ -86,7 +87,8 @@ export class GitWorkflow {
86
87
  failOnChanges,
87
88
  gitConfigDir,
88
89
  logger,
89
- matchedFileChunks,
90
+ matchedFiles,
91
+ maxArgLength,
90
92
  topLevelDir,
91
93
  }) {
92
94
  this.execGit = (args, options = {}) => execGit(args, { ...options, cwd: topLevelDir })
@@ -96,8 +98,9 @@ export class GitWorkflow {
96
98
  this.gitConfigDir = gitConfigDir
97
99
  this.failOnChanges = !!failOnChanges
98
100
  this.logger = logger
99
- /** @type {import('./getStagedFiles.js').StagedFile[][]} */
100
- this.matchedFileChunks = matchedFileChunks
101
+ /** @type {Set<import('./getStagedFiles.js').StagedFile>} */
102
+ this.matchedFiles = matchedFiles
103
+ this.maxArgLength = maxArgLength
101
104
  this.topLevelDir = topLevelDir
102
105
 
103
106
  /**
@@ -369,25 +372,27 @@ export class GitWorkflow {
369
372
  ? normalizePath(process.env.GIT_INDEX_FILE)
370
373
  : process.env.GIT_INDEX_FILE
371
374
 
372
- /** Needs to be run serially because of locking Git operation */
373
- for (const files of this.matchedFileChunks) {
374
- const accessCheckedFiles = await Promise.allSettled(
375
- files.map(async (f) => {
376
- if (f.status === 'D') {
377
- await fs.access(f.filepath)
378
- return f.filepath // File is no longer deleted and can be added
379
- } else {
380
- return f.filepath
381
- }
382
- })
383
- )
375
+ const accessCheckedFiles = await Promise.allSettled(
376
+ Array.from(this.matchedFiles).map(async (f) => {
377
+ if (f.status === 'D') {
378
+ await fs.access(f.filepath)
379
+ return f.filepath // File is no longer deleted and can be added
380
+ } else {
381
+ return f.filepath
382
+ }
383
+ })
384
+ )
384
385
 
385
- const addableFiles = accessCheckedFiles.flatMap((r) =>
386
- r.status === 'fulfilled' ? [r.value] : []
387
- )
386
+ const addableFiles = accessCheckedFiles.flatMap((r) =>
387
+ r.status === 'fulfilled' ? [r.value] : []
388
+ )
388
389
 
390
+ const addCommands = await chunkFilesForCommand('git add --', addableFiles, this.maxArgLength)
391
+
392
+ /** Needs to be run serially because of locking Git operation */
393
+ for (const { files } of addCommands) {
389
394
  debugLog('Updating active Git index: %s', activeIndexFile)
390
- await this.execGit(['add', '--', ...addableFiles])
395
+ await this.execGit(['add', '--', ...files])
391
396
  debugLog('Done updating Git index: %s', activeIndexFile)
392
397
 
393
398
  if (activeIndexFile?.endsWith('.lock')) {
@@ -402,7 +407,7 @@ export class GitWorkflow {
402
407
  */
403
408
  if (activeIndexFile !== defaultIndexLock) {
404
409
  debugLog('Updating default Git index again: %s', defaultIndexLock)
405
- await this.execGit(['add', '--', ...addableFiles], {
410
+ await this.execGit(['add', '--', ...files], {
406
411
  env: {
407
412
  GIT_INDEX_FILE: defaultIndexLock,
408
413
  },
package/lib/index.js CHANGED
@@ -2,6 +2,7 @@ import { assertGitVersion, MIN_GIT_VERSION } from './assertGitVersion.js'
2
2
  import { enableColors } from './colors.js'
3
3
  import { createDebug, enableDebug } from './debug.js'
4
4
  import { execGit } from './execGit.js'
5
+ import { getMaxArgLength } from './getSpawnedTasks.js'
5
6
  import {
6
7
  gitError,
7
8
  minGitVersionRequired,
@@ -27,24 +28,6 @@ import { getVersion } from './version.js'
27
28
 
28
29
  const debugLog = createDebug('lint-staged')
29
30
 
30
- /**
31
- * Get the maximum length of a command-line argument string based on current platform
32
- *
33
- * https://serverfault.com/questions/69430/what-is-the-maximum-length-of-a-command-line-in-mac-os-x
34
- * https://support.microsoft.com/en-us/help/830473/command-prompt-cmd-exe-command-line-string-limitation
35
- * https://unix.stackexchange.com/a/120652
36
- */
37
- const getMaxArgLength = () => {
38
- switch (process.platform) {
39
- case 'darwin':
40
- return 262144
41
- case 'win32':
42
- return 8191
43
- default:
44
- return 131072
45
- }
46
- }
47
-
48
31
  /**
49
32
  * @typedef {(...any) => void} LogFunction
50
33
  * @typedef {{ error: LogFunction, log: LogFunction, warn: LogFunction }} Logger
@@ -91,7 +74,7 @@ const lintStaged = async (
91
74
  hideAll = false,
92
75
  hideUnstaged = false,
93
76
  hidePartiallyStaged = !(hideAll || hideUnstaged),
94
- maxArgLength = getMaxArgLength() / 2,
77
+ maxArgLength = getMaxArgLength(),
95
78
  quiet = false,
96
79
  relative = false,
97
80
  // Stashing should be disabled by default when the `diff` option is used
package/lib/runAll.js CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  import path from 'node:path'
4
4
 
5
- import { chunkFiles } from './chunkFiles.js'
6
5
  import { dim } from './colors.js'
7
6
  import { createDebug } from './debug.js'
8
7
  import { execGit } from './execGit.js'
@@ -114,7 +113,7 @@ export const runAll = async (
114
113
  ) => {
115
114
  const thisLogger = configureLogger(logger, quiet)
116
115
 
117
- debugLog('Running all linter scripts...')
116
+ debugLog('Running all tasks...')
118
117
 
119
118
  // Resolve relative CWD option
120
119
  const hasExplicitCwd = !!cwd
@@ -204,91 +203,79 @@ export const runAll = async (
204
203
  for (const [configPath, { config, files }] of Object.entries(filesByConfig)) {
205
204
  const configName = configPath ? normalizePath(path.relative(cwd, configPath)) : 'Config object'
206
205
 
207
- const stagedFileChunks = chunkFiles({ baseDir: topLevelDir, files, maxArgLength, relative })
208
-
209
206
  // Use actual cwd if it's specified, or there's only a single config file.
210
207
  // Otherwise use the directory of the config file for each config group,
211
208
  // to make sure tasks are separated from each other.
212
209
  const groupCwd = hasExplicitCwd || !hasMultipleConfigs ? cwd : path.dirname(configPath)
213
210
 
214
- const chunkCount = stagedFileChunks.length
215
- if (chunkCount > 1) {
216
- debugLog('Chunked staged files from `%s` into %d part', configPath, chunkCount)
217
- }
218
-
219
- for (const [index, files] of stagedFileChunks.entries()) {
220
- const taskChunk = await Promise.all(
221
- generateTasks({ config, cwd: groupCwd, files, relative }).map((task) =>
222
- (isFunctionTask(task.commands)
223
- ? getFunctionTask({
224
- abortController,
225
- command: task.commands,
226
- continueOnError,
227
- files: task.fileList,
228
- })
229
- : getSpawnedTasks({
230
- abortController,
231
- commands: task.commands,
232
- continueOnError,
233
- cwd: groupCwd,
234
- files: task.fileList,
235
- topLevelDir,
236
- verbose,
237
- })
238
- ).then((subTasks) => {
239
- // Add files from task to match set
240
- task.fileList.forEach((file) => {
241
- // Make sure relative files are normalized to the
242
- // group cwd, because other there might be identical
243
- // relative filenames in the entire set.
244
- const normalizedFile = path.isAbsolute(file.filepath)
245
- ? file
246
- : {
247
- filepath: normalizePath(path.join(groupCwd, file.filepath)),
248
- status: file.status,
249
- }
250
-
251
- matchedFiles.add(normalizedFile)
211
+ const configTasks = await Promise.all(
212
+ generateTasks({ config, cwd: groupCwd, files, relative }).map((task) =>
213
+ (isFunctionTask(task.commands)
214
+ ? getFunctionTask({
215
+ abortController,
216
+ command: task.commands,
217
+ continueOnError,
218
+ files: task.fileList,
219
+ })
220
+ : getSpawnedTasks({
221
+ abortController,
222
+ commands: task.commands,
223
+ continueOnError,
224
+ cwd: groupCwd,
225
+ files: task.fileList,
226
+ maxArgLength,
227
+ topLevelDir,
228
+ verbose,
252
229
  })
230
+ ).then((subTasks) => {
231
+ // Add files from task to match set
232
+ task.fileList.forEach((file) => {
233
+ // Make sure relative files are normalized to the
234
+ // group cwd, because other there might be identical
235
+ // relative filenames in the entire set.
236
+ const normalizedFile = path.isAbsolute(file.filepath)
237
+ ? file
238
+ : {
239
+ filepath: normalizePath(path.join(groupCwd, file.filepath)),
240
+ status: file.status,
241
+ }
253
242
 
254
- hasDeprecatedGitAdd =
255
- hasDeprecatedGitAdd || subTasks.some((subTask) => subTask.title.includes('git add'))
243
+ matchedFiles.add(normalizedFile)
244
+ })
256
245
 
257
- const fileCount = task.fileList.length
246
+ hasDeprecatedGitAdd =
247
+ hasDeprecatedGitAdd || subTasks.some((subTask) => subTask.title.includes('git add'))
258
248
 
259
- return {
260
- title: `${task.pattern}${dim(
261
- ` — ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`
262
- )}`,
263
- task: subTasks,
264
- skip: () => {
265
- // Skip task when no files matched
266
- if (fileCount === 0) {
267
- return `${task.pattern}${dim(' — no files')}`
268
- }
249
+ const fileCount = task.fileList.length
269
250
 
270
- return false
271
- },
272
- }
273
- })
274
- )
275
- )
251
+ return {
252
+ title: `${task.pattern}${dim(` — ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`)}`,
253
+ task: subTasks,
254
+ skip: () => {
255
+ // Skip task when no files matched
256
+ if (fileCount === 0) {
257
+ return `${task.pattern}${dim(' — no files')}`
258
+ }
276
259
 
277
- tasks.push({
278
- title:
279
- `${configName}${dim(` — ${files.length} ${files.length > 1 ? 'files' : 'file'}`)}` +
280
- (chunkCount > 1 ? dim(` (chunk ${index + 1}/${chunkCount})...`) : ''),
281
- task: taskChunk,
282
- skip: () => {
283
- // Skip chunk when no every task is skipped (due to no matches)
284
- if (taskChunk.every((task) => task.skip())) {
285
- return `${configName}${dim(' — no tasks to run')}`
260
+ return false
261
+ },
286
262
  }
287
-
288
- return false
289
- },
290
- })
291
- }
263
+ })
264
+ )
265
+ )
266
+
267
+ tasks.push({
268
+ title: `${configName}${dim(` — ${files.length} ${files.length > 1 ? 'files' : 'file'}`)}`,
269
+ task: configTasks,
270
+ skip: () => {
271
+ // Skip chunk when no every task is skipped (due to no matches)
272
+ if (configTasks.every((task) => task.skip())) {
273
+ return `${configName}${dim(' — no tasks to run')}`
274
+ }
275
+
276
+ return false
277
+ },
278
+ })
292
279
  }
293
280
 
294
281
  if (hasDeprecatedGitAdd) {
@@ -300,16 +287,6 @@ export const runAll = async (
300
287
  return ctx
301
288
  }
302
289
 
303
- // Chunk matched files for better Windows compatibility
304
- /** @type {import('./getStagedFiles.js').StagedFile[][]} */
305
- const matchedFileChunks = chunkFiles({
306
- // matched files are relative to `cwd`, not `topLevelDir`, when `relative` is used
307
- baseDir: cwd,
308
- files: Array.from(matchedFiles),
309
- maxArgLength,
310
- relative: false,
311
- })
312
-
313
290
  const git = new GitWorkflow({
314
291
  allowEmpty,
315
292
  diff,
@@ -317,7 +294,8 @@ export const runAll = async (
317
294
  failOnChanges,
318
295
  gitConfigDir,
319
296
  logger: thisLogger,
320
- matchedFileChunks,
297
+ matchedFiles,
298
+ maxArgLength,
321
299
  topLevelDir,
322
300
  })
323
301
 
@@ -6,15 +6,15 @@ const debugLog = createDebug('lint-staged:runParallelTasks')
6
6
 
7
7
  /** @param {Number | boolean} options.concurrent Boolean value for whether to run concurrently */
8
8
  export const parseConcurrency = (concurrent) => {
9
- if (concurrent === false || concurrent < 1 || Number.isNaN(concurrent)) {
10
- return 1
9
+ if (concurrent === true || concurrent === Infinity) {
10
+ return Infinity
11
11
  }
12
12
 
13
- if (concurrent === true) {
14
- return Infinity
13
+ if (concurrent === false || concurrent < 1 || !Number.isInteger(concurrent)) {
14
+ return 1
15
15
  }
16
16
 
17
- return parseInt(concurrent)
17
+ return concurrent
18
18
  }
19
19
 
20
20
  /**
@@ -8,6 +8,9 @@ import { InvalidOptionsError } from './symbols.js'
8
8
 
9
9
  const debugLog = createDebug('lint-staged:validateOptions')
10
10
 
11
+ /** @type {(value: number) => boolean} */
12
+ const isValidIntegerValue = (value) => Number.isInteger(value) || value === Infinity
13
+
11
14
  /**
12
15
  * Validate lint-staged options, either from the Node.js API or the command line flags.
13
16
  * @param {*} options
@@ -17,26 +20,41 @@ const debugLog = createDebug('lint-staged:validateOptions')
17
20
  export const validateOptions = async (options = {}, logger) => {
18
21
  debugLog('Validating options...')
19
22
 
23
+ const { concurrent, cwd, maxArgLength } = options
24
+
20
25
  if (
21
- options.concurrent !== undefined &&
22
- typeof options.concurrent !== 'boolean' &&
23
- (!Number.isInteger(options.concurrent) || Number.isNaN(options.concurrent))
26
+ concurrent !== undefined &&
27
+ typeof concurrent !== 'boolean' &&
28
+ (!isValidIntegerValue(concurrent) || concurrent < 1)
24
29
  ) {
25
- logger.error(invalidOption('concurrent', `${options.concurrent}`, 'Must be boolean or integer'))
30
+ logger.error(
31
+ invalidOption('concurrent', `${concurrent}`, 'Must be boolean, positive integer or Infinite')
32
+ )
26
33
  throw InvalidOptionsError
27
34
  }
28
35
 
29
36
  /** Ensure the passed cwd option exists; it might also be relative */
30
- if (typeof options.cwd === 'string') {
37
+ if (typeof cwd === 'string') {
31
38
  try {
32
- const resolved = path.resolve(options.cwd)
39
+ const resolved = path.resolve(cwd)
33
40
  await fs.access(resolved, constants.F_OK)
34
41
  } catch (error) {
35
42
  debugLog('Failed to validate options: %o', options)
36
- logger.error(invalidOption('cwd', options.cwd, error.message))
43
+ logger.error(invalidOption('cwd', cwd, error.message))
37
44
  throw InvalidOptionsError
38
45
  }
39
46
  }
40
47
 
48
+ if (
49
+ maxArgLength !== undefined &&
50
+ maxArgLength !== null &&
51
+ (!isValidIntegerValue(maxArgLength) || maxArgLength < 1)
52
+ ) {
53
+ logger.error(
54
+ invalidOption('maxArgLength', `${maxArgLength}`, 'Must be positive integer, or Infinite')
55
+ )
56
+ throw InvalidOptionsError
57
+ }
58
+
41
59
  debugLog('Validated options: %o', options)
42
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lint-staged",
3
- "version": "17.1.0",
3
+ "version": "17.2.0",
4
4
  "description": "Lint files staged by git",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -69,8 +69,8 @@
69
69
  "cross-env": "10.1.0",
70
70
  "husky": "9.1.7",
71
71
  "mock-stdin": "1.0.0",
72
- "oxfmt": "0.59.0",
73
- "oxlint": "1.74.0",
72
+ "oxfmt": "0.60.0",
73
+ "oxlint": "1.75.0",
74
74
  "semver": "7.8.5",
75
75
  "vitest": "4.1.10"
76
76
  },
package/lib/chunkFiles.js DELETED
@@ -1,65 +0,0 @@
1
- import path from 'node:path'
2
-
3
- import { createDebug } from './debug.js'
4
- import { normalizePath } from './normalizePath.js'
5
-
6
- const debugLog = createDebug('lint-staged:chunkFiles')
7
-
8
- /**
9
- * Chunk array into sub-arrays
10
- * @param {Array} arr
11
- * @param {Number} chunkCount
12
- * @returns {Array<Array>}
13
- */
14
- const chunkArray = (arr, chunkCount) => {
15
- if (chunkCount === 1) return [arr]
16
- const chunked = []
17
- let position = 0
18
- for (let i = 0; i < chunkCount; i++) {
19
- const chunkLength = Math.ceil((arr.length - position) / (chunkCount - i))
20
- chunked.push([])
21
- chunked[i] = arr.slice(position, chunkLength + position)
22
- position += chunkLength
23
- }
24
- return chunked
25
- }
26
-
27
- /**
28
- * Chunk files into sub-arrays based on the length of the resulting argument string
29
- *
30
- * @typedef {import('./getStagedFiles.js').StagedFile[]} StagedFile
31
- *
32
- * @param {Object} opts
33
- * @param {Array<StagedFile>} opts.files
34
- * @param {String} [opts.baseDir] The optional base directory to resolve relative paths.
35
- * @param {number} [opts.maxArgLength] the maximum argument string length
36
- * @param {Boolean} [opts.relative] whether files are relative to `topLevelDir` or should be resolved as absolute
37
- * @returns {Array<Array<StagedFile>>}
38
- */
39
- export const chunkFiles = ({ files, baseDir, maxArgLength = null, relative = false }) => {
40
- const normalizedFiles = files.map((file) => {
41
- return {
42
- filepath: normalizePath(
43
- relative || !baseDir ? file.filepath : path.resolve(baseDir, file.filepath)
44
- ),
45
- status: file.status,
46
- }
47
- })
48
-
49
- if (!maxArgLength) {
50
- debugLog('Skip chunking files because of undefined maxArgLength')
51
- return [normalizedFiles] // wrap in an array to return a single chunk
52
- }
53
-
54
- /** Calculate total character length of all filepaths, with added spaces in between */
55
- const fileListLength =
56
- normalizedFiles.reduce((sum, file) => sum + file.filepath.length, 0) +
57
- Math.max(normalizedFiles.length - 1, 0)
58
-
59
- debugLog(
60
- `Resolved an argument string length of ${fileListLength} characters from ${normalizedFiles.length} files`
61
- )
62
- const chunkCount = Math.min(Math.ceil(fileListLength / maxArgLength), normalizedFiles.length)
63
- debugLog(`Creating ${chunkCount} chunks for maxArgLength of ${maxArgLength}`)
64
- return chunkArray(normalizedFiles, chunkCount)
65
- }