@depup/lint-staged 16.3.2-depup.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/LICENSE +21 -0
- package/MIGRATION.md +72 -0
- package/README.md +1120 -0
- package/bin/lint-staged.js +175 -0
- package/lib/chunkFiles.js +65 -0
- package/lib/colors.js +106 -0
- package/lib/configFiles.js +30 -0
- package/lib/debug.js +27 -0
- package/lib/execGit.js +37 -0
- package/lib/figures.js +9 -0
- package/lib/file.js +53 -0
- package/lib/generateTasks.js +69 -0
- package/lib/getAbortController.js +18 -0
- package/lib/getDiffCommand.js +19 -0
- package/lib/getFunctionTask.js +37 -0
- package/lib/getRenderer.js +63 -0
- package/lib/getSpawnedTask.js +160 -0
- package/lib/getSpawnedTasks.js +76 -0
- package/lib/getStagedFiles.js +86 -0
- package/lib/gitWorkflow.js +434 -0
- package/lib/groupFilesByConfig.js +62 -0
- package/lib/index.d.ts +125 -0
- package/lib/index.js +184 -0
- package/lib/killSubprocesses.js +42 -0
- package/lib/loadConfig.js +102 -0
- package/lib/messages.js +91 -0
- package/lib/normalizePath.js +50 -0
- package/lib/parseGitZOutput.js +11 -0
- package/lib/printTaskOutput.js +12 -0
- package/lib/readStdin.js +19 -0
- package/lib/resolveConfig.js +15 -0
- package/lib/resolveGitRepo.js +65 -0
- package/lib/runAll.js +372 -0
- package/lib/searchConfigs.js +186 -0
- package/lib/state.js +102 -0
- package/lib/symbols.js +29 -0
- package/lib/validateBraces.js +95 -0
- package/lib/validateConfig.js +108 -0
- package/lib/validateOptions.js +33 -0
- package/lib/version.js +6 -0
- package/package.json +93 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { createDebug } from './debug.js'
|
|
6
|
+
import { execGit } from './execGit.js'
|
|
7
|
+
import { readFile, unlink, writeFile } from './file.js'
|
|
8
|
+
import { getDiffCommand } from './getDiffCommand.js'
|
|
9
|
+
import { parseGitZOutput } from './parseGitZOutput.js'
|
|
10
|
+
import {
|
|
11
|
+
ApplyEmptyCommitError,
|
|
12
|
+
FailOnChangesError,
|
|
13
|
+
GetBackupStashError,
|
|
14
|
+
GitError,
|
|
15
|
+
HideUnstagedChangesError,
|
|
16
|
+
RestoreMergeStatusError,
|
|
17
|
+
RestoreOriginalStateError,
|
|
18
|
+
RestoreUnstagedChangesError,
|
|
19
|
+
} from './symbols.js'
|
|
20
|
+
|
|
21
|
+
const debugLog = createDebug('lint-staged:GitWorkflow')
|
|
22
|
+
|
|
23
|
+
const MERGE_HEAD = 'MERGE_HEAD'
|
|
24
|
+
const MERGE_MODE = 'MERGE_MODE'
|
|
25
|
+
const MERGE_MSG = 'MERGE_MSG'
|
|
26
|
+
|
|
27
|
+
// In git status machine output, renames are presented as `to`NUL`from`
|
|
28
|
+
// When diffing, both need to be taken into account, but in some cases on the `to`.
|
|
29
|
+
// eslint-disable-next-line no-control-regex
|
|
30
|
+
const RENAME = /\x00/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* From list of files, split renames and flatten into two files `to`NUL`from`.
|
|
34
|
+
* @param {string[]} files
|
|
35
|
+
* @param {Boolean} [includeRenameFrom=true] Whether or not to include the `from` renamed file, which is no longer on disk
|
|
36
|
+
*/
|
|
37
|
+
const processRenames = (files, includeRenameFrom = true) =>
|
|
38
|
+
files.reduce((flattened, file) => {
|
|
39
|
+
if (RENAME.test(file)) {
|
|
40
|
+
const [to, from] = file.split(RENAME)
|
|
41
|
+
if (includeRenameFrom) flattened.push(from)
|
|
42
|
+
flattened.push(to)
|
|
43
|
+
} else {
|
|
44
|
+
flattened.push(file)
|
|
45
|
+
}
|
|
46
|
+
return flattened
|
|
47
|
+
}, [])
|
|
48
|
+
|
|
49
|
+
export const STASH = 'lint-staged automatic backup'
|
|
50
|
+
|
|
51
|
+
const PATCH_UNSTAGED = 'lint-staged_unstaged.patch'
|
|
52
|
+
|
|
53
|
+
const GIT_DIFF_ARGS = [
|
|
54
|
+
'--binary', // support binary files
|
|
55
|
+
'--unified=0', // do not add lines around diff for consistent behaviour
|
|
56
|
+
'--no-color', // disable colors for consistent behaviour
|
|
57
|
+
'--no-ext-diff', // disable external diff tools for consistent behaviour
|
|
58
|
+
'--src-prefix=a/', // force prefix for consistent behaviour
|
|
59
|
+
'--dst-prefix=b/', // force prefix for consistent behaviour
|
|
60
|
+
'--patch', // output a patch that can be applied
|
|
61
|
+
'--submodule=short', // always use the default short format for submodules
|
|
62
|
+
]
|
|
63
|
+
const GIT_APPLY_ARGS = ['-v', '--whitespace=nowarn', '--recount', '--unidiff-zero']
|
|
64
|
+
|
|
65
|
+
const handleError = (error, ctx, symbol) => {
|
|
66
|
+
ctx.errors.add(GitError)
|
|
67
|
+
if (symbol) ctx.errors.add(symbol)
|
|
68
|
+
throw error
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const calculateSha256 = (input) => crypto.createHash('sha256').update(input, 'utf-8').digest('hex')
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The lines are wrapped in double quotes
|
|
75
|
+
* @returns {string[]}
|
|
76
|
+
*/
|
|
77
|
+
const cleanGitStashOutput = (lines) => lines.map((line) => line.replace(/^"(.*)"$/, '$1'))
|
|
78
|
+
|
|
79
|
+
export class GitWorkflow {
|
|
80
|
+
/**
|
|
81
|
+
* @param {Object} opts
|
|
82
|
+
* @param {import('./getStagedFiles.js').StagedFile[][]} opts.matchedFileChunks
|
|
83
|
+
*/
|
|
84
|
+
constructor({
|
|
85
|
+
allowEmpty,
|
|
86
|
+
diff,
|
|
87
|
+
diffFilter,
|
|
88
|
+
failOnChanges,
|
|
89
|
+
gitConfigDir,
|
|
90
|
+
matchedFileChunks,
|
|
91
|
+
topLevelDir,
|
|
92
|
+
}) {
|
|
93
|
+
this.execGit = (args, options = {}) => execGit(args, { ...options, cwd: topLevelDir })
|
|
94
|
+
this.allowEmpty = allowEmpty
|
|
95
|
+
this.deletedFiles = []
|
|
96
|
+
this.diff = diff
|
|
97
|
+
this.diffFilter = diffFilter
|
|
98
|
+
this.gitConfigDir = gitConfigDir
|
|
99
|
+
this.failOnChanges = !!failOnChanges
|
|
100
|
+
/** @type {import('./getStagedFiles.js').StagedFile[][]} */
|
|
101
|
+
this.matchedFileChunks = matchedFileChunks
|
|
102
|
+
this.topLevelDir = topLevelDir
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* These three files hold state about an ongoing git merge
|
|
106
|
+
* Resolve paths during constructor
|
|
107
|
+
*/
|
|
108
|
+
this.mergeHeadFilename = path.resolve(gitConfigDir, MERGE_HEAD)
|
|
109
|
+
this.mergeModeFilename = path.resolve(gitConfigDir, MERGE_MODE)
|
|
110
|
+
this.mergeMsgFilename = path.resolve(gitConfigDir, MERGE_MSG)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get absolute path to file hidden inside .git
|
|
115
|
+
* @param {string} filename
|
|
116
|
+
*/
|
|
117
|
+
getHiddenFilepath(filename) {
|
|
118
|
+
return path.resolve(this.gitConfigDir, `./${filename}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get name of backup stash
|
|
123
|
+
*/
|
|
124
|
+
async getBackupStash(ctx) {
|
|
125
|
+
/** Print stash list with short hash and subject */
|
|
126
|
+
const stashes = await this.execGit(['stash', 'list', '--format="%h %s"', '-z'])
|
|
127
|
+
.then(parseGitZOutput)
|
|
128
|
+
.then(cleanGitStashOutput)
|
|
129
|
+
|
|
130
|
+
const index = stashes.findIndex((line) => line.startsWith(ctx.backupHash))
|
|
131
|
+
|
|
132
|
+
if (index === -1) {
|
|
133
|
+
ctx.errors.add(GetBackupStashError)
|
|
134
|
+
throw new Error('lint-staged automatic backup is missing!')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return String(index)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get a list of unstaged deleted files
|
|
142
|
+
*/
|
|
143
|
+
async getDeletedFiles() {
|
|
144
|
+
debugLog('Getting deleted files...')
|
|
145
|
+
const lsFiles = await this.execGit(['ls-files', '--deleted'])
|
|
146
|
+
const deletedFiles = lsFiles
|
|
147
|
+
.split('\n')
|
|
148
|
+
.filter(Boolean)
|
|
149
|
+
.map((file) => path.resolve(this.topLevelDir, file))
|
|
150
|
+
debugLog('Found deleted files:', deletedFiles)
|
|
151
|
+
return deletedFiles
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Save meta information about ongoing git merge
|
|
156
|
+
*/
|
|
157
|
+
async backupMergeStatus() {
|
|
158
|
+
debugLog('Backing up merge state...')
|
|
159
|
+
await Promise.all([
|
|
160
|
+
readFile(this.mergeHeadFilename).then((buffer) => (this.mergeHeadBuffer = buffer)),
|
|
161
|
+
readFile(this.mergeModeFilename).then((buffer) => (this.mergeModeBuffer = buffer)),
|
|
162
|
+
readFile(this.mergeMsgFilename).then((buffer) => (this.mergeMsgBuffer = buffer)),
|
|
163
|
+
])
|
|
164
|
+
debugLog('Done backing up merge state!')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Restore meta information about ongoing git merge
|
|
169
|
+
*/
|
|
170
|
+
async restoreMergeStatus(ctx) {
|
|
171
|
+
debugLog('Restoring merge state...')
|
|
172
|
+
try {
|
|
173
|
+
await Promise.all([
|
|
174
|
+
this.mergeHeadBuffer && writeFile(this.mergeHeadFilename, this.mergeHeadBuffer),
|
|
175
|
+
this.mergeModeBuffer && writeFile(this.mergeModeFilename, this.mergeModeBuffer),
|
|
176
|
+
this.mergeMsgBuffer && writeFile(this.mergeMsgFilename, this.mergeMsgBuffer),
|
|
177
|
+
])
|
|
178
|
+
debugLog('Done restoring merge state!')
|
|
179
|
+
} catch (error) {
|
|
180
|
+
debugLog('Failed restoring merge state with error:')
|
|
181
|
+
debugLog(error)
|
|
182
|
+
handleError(
|
|
183
|
+
new Error('Merge state could not be restored due to an error!'),
|
|
184
|
+
ctx,
|
|
185
|
+
RestoreMergeStatusError
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get a list of all files with both staged and unstaged modifications.
|
|
192
|
+
* Renames have special treatment, since the single status line includes
|
|
193
|
+
* both the "from" and "to" filenames, where "from" is no longer on disk.
|
|
194
|
+
*/
|
|
195
|
+
async getUnstagedFiles({ onlyPartial = false } = {}) {
|
|
196
|
+
debugLog('Getting partially staged files...')
|
|
197
|
+
const status = await this.execGit(['status', '-z'])
|
|
198
|
+
/**
|
|
199
|
+
* See https://git-scm.com/docs/git-status#_short_format
|
|
200
|
+
* Entries returned in machine format are separated by a NUL character.
|
|
201
|
+
* The first letter of each entry represents current index status,
|
|
202
|
+
* and second the working tree. Index and working tree status codes are
|
|
203
|
+
* separated from the file name by a space. If an entry includes a
|
|
204
|
+
* renamed file, the file names are separated by a NUL character
|
|
205
|
+
* (e.g. `to`\0`from`)
|
|
206
|
+
*/
|
|
207
|
+
const unstagedFiles = status
|
|
208
|
+
// eslint-disable-next-line no-control-regex
|
|
209
|
+
.split(/\x00(?=[ AMDRCU?!]{2} |$)/)
|
|
210
|
+
.filter((line) => {
|
|
211
|
+
const [index, workingTree] = line
|
|
212
|
+
const updatedInIndex = index !== ' ' && index !== '?'
|
|
213
|
+
const updatedInWorkingTree = workingTree !== ' ' && workingTree !== '?'
|
|
214
|
+
|
|
215
|
+
if (onlyPartial) {
|
|
216
|
+
return updatedInIndex && updatedInWorkingTree
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return updatedInWorkingTree
|
|
220
|
+
})
|
|
221
|
+
.map((line) => line.slice(3)) // Remove first three letters (index, workingTree, and a whitespace)
|
|
222
|
+
.filter(Boolean) // Filter empty strings
|
|
223
|
+
debugLog(`Found ${onlyPartial ? 'partially staged' : 'unstaged'} files:`, unstagedFiles)
|
|
224
|
+
return unstagedFiles.length ? unstagedFiles : null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Create a diff of unstaged or partially staged files and backup stash if enabled.
|
|
229
|
+
*/
|
|
230
|
+
async prepare(ctx, task) {
|
|
231
|
+
try {
|
|
232
|
+
debugLog(task.title)
|
|
233
|
+
|
|
234
|
+
if (ctx.shouldBackup) {
|
|
235
|
+
// When backup is enabled, the revert will clear ongoing merge status.
|
|
236
|
+
await this.backupMergeStatus()
|
|
237
|
+
|
|
238
|
+
// Get a list of unstaged deleted files, because certain bugs might cause them to reappear:
|
|
239
|
+
// - in git versions =< 2.13.0 the `git stash --keep-index` option resurrects deleted files
|
|
240
|
+
// - git stash can't infer RD or MD states correctly, and will lose the deletion
|
|
241
|
+
this.deletedFiles = await this.getDeletedFiles()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (ctx.shouldHideUnstaged) {
|
|
245
|
+
this.unstagedFiles = await this.getUnstagedFiles({ onlyPartial: false })
|
|
246
|
+
ctx.hasFilesToHide = !!this.unstagedFiles
|
|
247
|
+
} else if (ctx.shouldHidePartiallyStaged) {
|
|
248
|
+
this.unstagedFiles = await this.getUnstagedFiles({ onlyPartial: true })
|
|
249
|
+
ctx.hasFilesToHide = !!this.unstagedFiles
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (this.unstagedFiles) {
|
|
253
|
+
const unstagedPatch = this.getHiddenFilepath(PATCH_UNSTAGED)
|
|
254
|
+
ctx.unstagedPatch = unstagedPatch
|
|
255
|
+
const files = processRenames(this.unstagedFiles)
|
|
256
|
+
await this.execGit(['diff', ...GIT_DIFF_ARGS, '--output', unstagedPatch, '--', ...files])
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (ctx.shouldBackup) {
|
|
260
|
+
if (ctx.shouldHideUnstaged) {
|
|
261
|
+
/** Save stash of all changes, clearing the working tree but keeping staged files as-is */
|
|
262
|
+
await this.execGit(['stash', 'push', '--keep-index', '--message', STASH])
|
|
263
|
+
/** Print stash list with short hash and subject */
|
|
264
|
+
const stashes = await this.execGit(['stash', 'list', '--format="%h %s"', '-z'])
|
|
265
|
+
.then(parseGitZOutput)
|
|
266
|
+
.then(cleanGitStashOutput)
|
|
267
|
+
|
|
268
|
+
/** The stash line starts with the short hash, so we split from space and choose the first part */
|
|
269
|
+
ctx.backupHash = stashes.find((line) => line.includes(STASH))?.split(' ')[0]
|
|
270
|
+
} else {
|
|
271
|
+
/** Save stash of all changes, keeping all files as-is */
|
|
272
|
+
const stashHash = await this.execGit(['stash', 'create'])
|
|
273
|
+
ctx.backupHash = await this.execGit(['rev-parse', '--short', stashHash])
|
|
274
|
+
await this.execGit(['stash', 'store', '--quiet', '--message', STASH, ctx.backupHash])
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
task.title = `Backed up original state in git stash (${ctx.backupHash})`
|
|
278
|
+
debugLog(task.title)
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
handleError(error, ctx)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async hidePartiallyStagedChanges(ctx) {
|
|
286
|
+
try {
|
|
287
|
+
const files = processRenames(this.unstagedFiles, false)
|
|
288
|
+
await this.execGit(['checkout', '--force', '--', ...files])
|
|
289
|
+
} catch (error) {
|
|
290
|
+
/**
|
|
291
|
+
* `git checkout --force` doesn't throw errors, so it shouldn't be possible to get here.
|
|
292
|
+
* If this does fail, the handleError method will set ctx.gitError and lint-staged will fail.
|
|
293
|
+
*/
|
|
294
|
+
handleError(error, ctx, HideUnstagedChangesError)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async runTasks(ctx, task, { listrTasks, concurrent }) {
|
|
299
|
+
if (ctx.shouldFailOnChanges) {
|
|
300
|
+
debugLog(
|
|
301
|
+
'Calculating SHA-256 hash of unstaged changes because "--fail-on-changes" was used...'
|
|
302
|
+
)
|
|
303
|
+
const diff = await this.execGit(['diff', '--patch', '--unified=0'])
|
|
304
|
+
ctx.unstagedDiffSha256 = calculateSha256(diff)
|
|
305
|
+
debugLog('SHA-256 hash of unstaged changes is %s', ctx.unstagedDiffSha256)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return task.newListr(listrTasks, { concurrent })
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Applies back task modifications, and unstaged changes hidden in the stash.
|
|
313
|
+
* In case of a merge-conflict retry with 3-way merge.
|
|
314
|
+
*/
|
|
315
|
+
async applyModifications(ctx) {
|
|
316
|
+
if (ctx.shouldFailOnChanges) {
|
|
317
|
+
debugLog(
|
|
318
|
+
'Calculating SHA-256 hash of changes after tasks because "--fail-on-changes" was used...'
|
|
319
|
+
)
|
|
320
|
+
const diff = await this.execGit(['diff', '--patch', '--unified=0'])
|
|
321
|
+
const diffSha256 = calculateSha256(diff)
|
|
322
|
+
debugLog('SHA-256 hash of changes after tasks is %s', diffSha256)
|
|
323
|
+
if (ctx.unstagedDiffSha256 !== diffSha256) {
|
|
324
|
+
ctx.errors.add(FailOnChangesError)
|
|
325
|
+
throw new Error('Tasks modified files and --fail-on-changes was used!')
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
debugLog('Adding task modifications to index...')
|
|
330
|
+
|
|
331
|
+
// `matchedFileChunks` includes staged files that lint-staged originally detected and matched against a task.
|
|
332
|
+
// Add only these files so any 3rd-party edits to other files won't be included in the commit.
|
|
333
|
+
// These additions per chunk are run "serially" to prevent race conditions.
|
|
334
|
+
// Git add creates a lockfile in the repo causing concurrent operations to fail.
|
|
335
|
+
for (const files of this.matchedFileChunks) {
|
|
336
|
+
const accessCheckedFiles = await Promise.allSettled(
|
|
337
|
+
files.map(async (f) => {
|
|
338
|
+
if (f.status === 'D') {
|
|
339
|
+
await fs.access(f.filepath)
|
|
340
|
+
return f.filepath // File is no longer deleted and can be added
|
|
341
|
+
} else {
|
|
342
|
+
return f.filepath
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
const addableFiles = accessCheckedFiles.flatMap((r) =>
|
|
348
|
+
r.status === 'fulfilled' ? [r.value] : []
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
await this.execGit(['add', '--', ...addableFiles])
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
debugLog('Done adding task modifications to index!')
|
|
355
|
+
|
|
356
|
+
const stagedFilesAfterAdd = await this.execGit([
|
|
357
|
+
...getDiffCommand(this.diff, this.diffFilter),
|
|
358
|
+
'--name-only',
|
|
359
|
+
'-z',
|
|
360
|
+
])
|
|
361
|
+
|
|
362
|
+
if (!stagedFilesAfterAdd && !this.allowEmpty) {
|
|
363
|
+
// Tasks reverted all staged changes and the commit would be empty
|
|
364
|
+
// Throw error to stop commit unless `--allow-empty` was used
|
|
365
|
+
handleError(new Error('Prevented an empty git commit!'), ctx, ApplyEmptyCommitError)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Restore unstaged changes to partially changed files. If it at first fails,
|
|
371
|
+
* this is probably because of conflicts between new task modifications.
|
|
372
|
+
* 3-way merge usually fixes this, and in case it doesn't we should just give up and throw.
|
|
373
|
+
*/
|
|
374
|
+
async restoreUnstagedChanges(ctx) {
|
|
375
|
+
debugLog('Restoring unstaged changes...')
|
|
376
|
+
const unstagedPatch = this.getHiddenFilepath(PATCH_UNSTAGED)
|
|
377
|
+
try {
|
|
378
|
+
await this.execGit(['apply', ...GIT_APPLY_ARGS, unstagedPatch])
|
|
379
|
+
} catch (applyError) {
|
|
380
|
+
debugLog('Error while restoring changes:')
|
|
381
|
+
debugLog(applyError)
|
|
382
|
+
debugLog('Retrying with 3-way merge')
|
|
383
|
+
// Retry with a 3-way merge if normal apply fails
|
|
384
|
+
try {
|
|
385
|
+
await this.execGit(['apply', ...GIT_APPLY_ARGS, '--3way', unstagedPatch])
|
|
386
|
+
} catch (threeWayApplyError) {
|
|
387
|
+
debugLog('Error while restoring unstaged changes using 3-way merge:')
|
|
388
|
+
debugLog(threeWayApplyError)
|
|
389
|
+
handleError(
|
|
390
|
+
new Error('Unstaged changes could not be restored due to a merge conflict!'),
|
|
391
|
+
ctx,
|
|
392
|
+
RestoreUnstagedChangesError
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Restore original HEAD state in case of errors
|
|
400
|
+
*/
|
|
401
|
+
async restoreOriginalState(ctx) {
|
|
402
|
+
try {
|
|
403
|
+
debugLog('Restoring original state...')
|
|
404
|
+
await this.execGit(['reset', '--hard', 'HEAD'])
|
|
405
|
+
await this.execGit(['stash', 'apply', '--quiet', '--index', await this.getBackupStash(ctx)])
|
|
406
|
+
|
|
407
|
+
// Restore meta information about ongoing git merge
|
|
408
|
+
await this.restoreMergeStatus(ctx)
|
|
409
|
+
|
|
410
|
+
// If stashing resurrected deleted files, clean them out
|
|
411
|
+
await Promise.all(this.deletedFiles.map((file) => unlink(file)))
|
|
412
|
+
|
|
413
|
+
// Clean out patch
|
|
414
|
+
await unlink(this.getHiddenFilepath(PATCH_UNSTAGED))
|
|
415
|
+
|
|
416
|
+
debugLog('Done restoring original state!')
|
|
417
|
+
} catch (error) {
|
|
418
|
+
handleError(error, ctx, RestoreOriginalStateError)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Drop the created stashes after everything has run
|
|
424
|
+
*/
|
|
425
|
+
async cleanup(ctx) {
|
|
426
|
+
try {
|
|
427
|
+
debugLog('Dropping backup stash...')
|
|
428
|
+
await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)])
|
|
429
|
+
debugLog('Done dropping backup stash!')
|
|
430
|
+
} catch (error) {
|
|
431
|
+
handleError(error, ctx)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { createDebug } from './debug.js'
|
|
4
|
+
|
|
5
|
+
const debugLog = createDebug('lint-staged:groupFilesByConfig')
|
|
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
|
+
*/
|
|
11
|
+
export const groupFilesByConfig = async ({ configs, files, singleConfigMode }) => {
|
|
12
|
+
debugLog('Grouping %d files by %d configurations', files.length, Object.keys(configs).length)
|
|
13
|
+
|
|
14
|
+
/** @type {Set<StagedFile>} */
|
|
15
|
+
const filesSet = new Set(files)
|
|
16
|
+
|
|
17
|
+
/** @type {{[key: string]: { config: any; files: StagedFile[] } }} */
|
|
18
|
+
const filesByConfig = {}
|
|
19
|
+
|
|
20
|
+
/** Configs are sorted deepest first by `searchConfigs` */
|
|
21
|
+
for (const [filepath, config] of Object.entries(configs)) {
|
|
22
|
+
/** When passed an explicit config object via the Node.js API‚ or an explicit path, skip logic */
|
|
23
|
+
if (singleConfigMode) {
|
|
24
|
+
filesByConfig[filepath] = { config, files }
|
|
25
|
+
break
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const dir = path.normalize(path.dirname(filepath))
|
|
29
|
+
|
|
30
|
+
/** Check if file is inside directory of the configuration file */
|
|
31
|
+
const isInsideDir = (file) => {
|
|
32
|
+
const relative = path.relative(dir, file.filepath)
|
|
33
|
+
return relative && !relative.startsWith('..') && !path.isAbsolute(relative)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** This config should match all files since it has a parent glob */
|
|
37
|
+
const includeAllFiles = Object.keys(config).some((glob) => glob.startsWith('..'))
|
|
38
|
+
|
|
39
|
+
const scopedFiles = new Set(includeAllFiles ? filesSet : undefined)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Without a parent glob, if file is inside the config file's directory,
|
|
43
|
+
* assign it to that configuration.
|
|
44
|
+
*/
|
|
45
|
+
if (!includeAllFiles) {
|
|
46
|
+
filesSet.forEach((file) => {
|
|
47
|
+
if (isInsideDir(file)) {
|
|
48
|
+
scopedFiles.add(file)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Files should only match a single config */
|
|
54
|
+
scopedFiles.forEach((file) => {
|
|
55
|
+
filesSet.delete(file)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
filesByConfig[filepath] = { config, files: Array.from(scopedFiles) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return filesByConfig
|
|
62
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
type SyncGenerateTask = (stagedFileNames: readonly string[]) => string | string[]
|
|
2
|
+
|
|
3
|
+
type AsyncGenerateTask = (stagedFileNames: readonly string[]) => Promise<string | string[]>
|
|
4
|
+
|
|
5
|
+
type GenerateTask = SyncGenerateTask | AsyncGenerateTask
|
|
6
|
+
|
|
7
|
+
type TaskFunction = {
|
|
8
|
+
title: string
|
|
9
|
+
task: (stagedFileNames: readonly string[]) => void | Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type Configuration =
|
|
13
|
+
| Record<string, string | TaskFunction | GenerateTask | (string | GenerateTask)[]>
|
|
14
|
+
| GenerateTask
|
|
15
|
+
|
|
16
|
+
export type Options = {
|
|
17
|
+
/**
|
|
18
|
+
* Allow empty commits when tasks revert all staged changes
|
|
19
|
+
* @default false
|
|
20
|
+
*/
|
|
21
|
+
allowEmpty?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* Enable or disable ANSI color codes in output. By default value is auto-detected
|
|
24
|
+
* and controlled by `FORCE_COLOR` or `NO_COLOR` env variables.
|
|
25
|
+
*/
|
|
26
|
+
color?: boolean
|
|
27
|
+
/**
|
|
28
|
+
* The number of tasks to run concurrently, or `false` to run tasks serially
|
|
29
|
+
* @default true
|
|
30
|
+
*/
|
|
31
|
+
concurrent?: boolean | number
|
|
32
|
+
/**
|
|
33
|
+
* Manual task configuration; disables automatic config file discovery when used
|
|
34
|
+
*/
|
|
35
|
+
config?: Configuration
|
|
36
|
+
/**
|
|
37
|
+
* Path to single configuration file; disables automatic config file discovery when used
|
|
38
|
+
*/
|
|
39
|
+
configPath?: string
|
|
40
|
+
/**
|
|
41
|
+
* Run all tasks to completion even if one fails
|
|
42
|
+
* @default false
|
|
43
|
+
*/
|
|
44
|
+
continueOnError?: boolean
|
|
45
|
+
/**
|
|
46
|
+
* Working directory to run all tasks in, defaults to current working directory
|
|
47
|
+
*/
|
|
48
|
+
cwd?: string
|
|
49
|
+
/**
|
|
50
|
+
* Whether or not to enable debug output
|
|
51
|
+
* @default false
|
|
52
|
+
*/
|
|
53
|
+
debug?: boolean
|
|
54
|
+
/**
|
|
55
|
+
* Override the default `--staged` flag of `git diff` to get list of files.
|
|
56
|
+
* @warn changing this also implies `stash: false`.
|
|
57
|
+
* @example HEAD...origin/main
|
|
58
|
+
*/
|
|
59
|
+
diff?: string
|
|
60
|
+
/**
|
|
61
|
+
* Override the default `--diff-filter=ACMR` flag of `git diff` to get list of files
|
|
62
|
+
* @default "ACMR"
|
|
63
|
+
*/
|
|
64
|
+
diffFilter?: string
|
|
65
|
+
/**
|
|
66
|
+
* Fail with exit code 1 when tasks modify tracked files
|
|
67
|
+
* @default false
|
|
68
|
+
*/
|
|
69
|
+
failOnChanges?: boolean
|
|
70
|
+
/**
|
|
71
|
+
* Maximum argument string length, by default automatically detected
|
|
72
|
+
*/
|
|
73
|
+
maxArgLength?: number
|
|
74
|
+
/**
|
|
75
|
+
* Whether to hide unstaged changes from partially staged files before running tasks
|
|
76
|
+
* @default true
|
|
77
|
+
*/
|
|
78
|
+
hidePartiallyStaged?: boolean
|
|
79
|
+
/**
|
|
80
|
+
* Whether to hide all unstaged changes before running tasks
|
|
81
|
+
* @default false
|
|
82
|
+
*/
|
|
83
|
+
hideUnstaged?: boolean
|
|
84
|
+
/**
|
|
85
|
+
* Disable lint-staged’s own console output
|
|
86
|
+
* @default false
|
|
87
|
+
*/
|
|
88
|
+
quiet?: boolean
|
|
89
|
+
/**
|
|
90
|
+
* Pass filepaths relative to `CWD` to tasks, instead of absolute
|
|
91
|
+
* @default false
|
|
92
|
+
*/
|
|
93
|
+
relative?: boolean
|
|
94
|
+
/**
|
|
95
|
+
* Revert to original state in case of errors
|
|
96
|
+
* @default true
|
|
97
|
+
*/
|
|
98
|
+
revert?: boolean
|
|
99
|
+
/**
|
|
100
|
+
* Enable the backup stash, and revert in case of errors.
|
|
101
|
+
* @warn Disabling this also implies `hidePartiallyStaged: false`.
|
|
102
|
+
* @default true
|
|
103
|
+
*/
|
|
104
|
+
stash?: boolean
|
|
105
|
+
/**
|
|
106
|
+
* Show task output even when tasks succeed; by default only failed output is shown
|
|
107
|
+
* @default false
|
|
108
|
+
*/
|
|
109
|
+
verbose?: boolean
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type LogFunction = typeof console.log
|
|
113
|
+
|
|
114
|
+
type Logger = {
|
|
115
|
+
log: LogFunction
|
|
116
|
+
warn: LogFunction
|
|
117
|
+
error: LogFunction
|
|
118
|
+
debug: LogFunction
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @returns {boolean} `true` when all tasks were successful, `false` when some tasks failed with errors
|
|
123
|
+
* @throws {Error} when failed to some other errors
|
|
124
|
+
*/
|
|
125
|
+
export default function lintStaged(options: Options, logger?: Logger): Promise<boolean>
|