lint-staged 17.1.1 → 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.
@@ -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,25 +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
- * roughly half of the real maximum length
33
- *
34
- * https://serverfault.com/questions/69430/what-is-the-maximum-length-of-a-command-line-in-mac-os-x
35
- * https://support.microsoft.com/en-us/help/830473/command-prompt-cmd-exe-command-line-string-limitation
36
- * https://unix.stackexchange.com/a/120652
37
- */
38
- const getMaxArgLength = () => {
39
- switch (process.platform) {
40
- case 'darwin': // 262_144
41
- return 131_072
42
- case 'win32': // 8_191
43
- return 4_096
44
- default: // 131_072
45
- return 65_536
46
- }
47
- }
48
-
49
31
  /**
50
32
  * @typedef {(...any) => void} LogFunction
51
33
  * @typedef {{ error: LogFunction, log: LogFunction, warn: LogFunction }} Logger
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lint-staged",
3
- "version": "17.1.1",
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 || maxArgLength === Infinity) {
50
- debugLog(`Skip chunking files because of maxArgLength is ${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
- }