UNPKG

12.6 kBJavaScriptView Raw
1import path from 'node:path'
2
3import debug from 'debug'
4
5import { execGit } from './execGit.js'
6import { readFile, unlink, writeFile } from './file.js'
7import { getDiffCommand } from './getDiffCommand.js'
8import {
9 GitError,
10 RestoreOriginalStateError,
11 ApplyEmptyCommitError,
12 GetBackupStashError,
13 HideUnstagedChangesError,
14 RestoreMergeStatusError,
15 RestoreUnstagedChangesError,
16} from './symbols.js'
17
18const debugLog = debug('lint-staged:GitWorkflow')
19
20const MERGE_HEAD = 'MERGE_HEAD'
21const MERGE_MODE = 'MERGE_MODE'
22const MERGE_MSG = 'MERGE_MSG'
23
24// In git status machine output, renames are presented as `to`NUL`from`
25// When diffing, both need to be taken into account, but in some cases on the `to`.
26// eslint-disable-next-line no-control-regex
27const RENAME = /\x00/
28
29/**
30 * From list of files, split renames and flatten into two files `to`NUL`from`.
31 * @param {string[]} files
32 * @param {Boolean} [includeRenameFrom=true] Whether or not to include the `from` renamed file, which is no longer on disk
33 */
34const processRenames = (files, includeRenameFrom = true) =>
35 files.reduce((flattened, file) => {
36 if (RENAME.test(file)) {
37 const [to, from] = file.split(RENAME)
38 if (includeRenameFrom) flattened.push(from)
39 flattened.push(to)
40 } else {
41 flattened.push(file)
42 }
43 return flattened
44 }, [])
45
46export const STASH = 'lint-staged automatic backup'
47
48const PATCH_UNSTAGED = 'lint-staged_unstaged.patch'
49
50const GIT_DIFF_ARGS = [
51 '--binary', // support binary files
52 '--unified=0', // do not add lines around diff for consistent behaviour
53 '--no-color', // disable colors for consistent behaviour
54 '--no-ext-diff', // disable external diff tools for consistent behaviour
55 '--src-prefix=a/', // force prefix for consistent behaviour
56 '--dst-prefix=b/', // force prefix for consistent behaviour
57 '--patch', // output a patch that can be applied
58 '--submodule=short', // always use the default short format for submodules
59]
60const GIT_APPLY_ARGS = ['-v', '--whitespace=nowarn', '--recount', '--unidiff-zero']
61
62const handleError = (error, ctx, symbol) => {
63 ctx.errors.add(GitError)
64 if (symbol) ctx.errors.add(symbol)
65 throw error
66}
67
68export class GitWorkflow {
69 constructor({ allowEmpty, gitConfigDir, gitDir, matchedFileChunks, diff, diffFilter }) {
70 this.execGit = (args, options = {}) => execGit(args, { ...options, cwd: gitDir })
71 this.deletedFiles = []
72 this.gitConfigDir = gitConfigDir
73 this.gitDir = gitDir
74 this.diff = diff
75 this.diffFilter = diffFilter
76 this.allowEmpty = allowEmpty
77 this.matchedFileChunks = matchedFileChunks
78
79 /**
80 * These three files hold state about an ongoing git merge
81 * Resolve paths during constructor
82 */
83 this.mergeHeadFilename = path.resolve(gitConfigDir, MERGE_HEAD)
84 this.mergeModeFilename = path.resolve(gitConfigDir, MERGE_MODE)
85 this.mergeMsgFilename = path.resolve(gitConfigDir, MERGE_MSG)
86 }
87
88 /**
89 * Get absolute path to file hidden inside .git
90 * @param {string} filename
91 */
92 getHiddenFilepath(filename) {
93 return path.resolve(this.gitConfigDir, `./${filename}`)
94 }
95
96 /**
97 * Get name of backup stash
98 */
99 async getBackupStash(ctx) {
100 const stashes = await this.execGit(['stash', 'list'])
101 const index = stashes.split('\n').findIndex((line) => line.includes(STASH))
102 if (index === -1) {
103 ctx.errors.add(GetBackupStashError)
104 throw new Error('lint-staged automatic backup is missing!')
105 }
106
107 /**
108 * https://github.com/okonet/lint-staged/issues/1121
109 * Detect MSYS in login shell mode and escape braces
110 * to prevent interpolation
111 */
112 if (!!process.env.MSYSTEM && !!process.env.LOGINSHELL) {
113 return `refs/stash@\\{${index}\\}`
114 }
115
116 return `refs/stash@{${index}}`
117 }
118
119 /**
120 * Get a list of unstaged deleted files
121 */
122 async getDeletedFiles() {
123 debugLog('Getting deleted files...')
124 const lsFiles = await this.execGit(['ls-files', '--deleted'])
125 const deletedFiles = lsFiles
126 .split('\n')
127 .filter(Boolean)
128 .map((file) => path.resolve(this.gitDir, file))
129 debugLog('Found deleted files:', deletedFiles)
130 return deletedFiles
131 }
132
133 /**
134 * Save meta information about ongoing git merge
135 */
136 async backupMergeStatus() {
137 debugLog('Backing up merge state...')
138 await Promise.all([
139 readFile(this.mergeHeadFilename).then((buffer) => (this.mergeHeadBuffer = buffer)),
140 readFile(this.mergeModeFilename).then((buffer) => (this.mergeModeBuffer = buffer)),
141 readFile(this.mergeMsgFilename).then((buffer) => (this.mergeMsgBuffer = buffer)),
142 ])
143 debugLog('Done backing up merge state!')
144 }
145
146 /**
147 * Restore meta information about ongoing git merge
148 */
149 async restoreMergeStatus(ctx) {
150 debugLog('Restoring merge state...')
151 try {
152 await Promise.all([
153 this.mergeHeadBuffer && writeFile(this.mergeHeadFilename, this.mergeHeadBuffer),
154 this.mergeModeBuffer && writeFile(this.mergeModeFilename, this.mergeModeBuffer),
155 this.mergeMsgBuffer && writeFile(this.mergeMsgFilename, this.mergeMsgBuffer),
156 ])
157 debugLog('Done restoring merge state!')
158 } catch (error) {
159 debugLog('Failed restoring merge state with error:')
160 debugLog(error)
161 handleError(
162 new Error('Merge state could not be restored due to an error!'),
163 ctx,
164 RestoreMergeStatusError
165 )
166 }
167 }
168
169 /**
170 * Get a list of all files with both staged and unstaged modifications.
171 * Renames have special treatment, since the single status line includes
172 * both the "from" and "to" filenames, where "from" is no longer on disk.
173 */
174 async getPartiallyStagedFiles() {
175 debugLog('Getting partially staged files...')
176 const status = await this.execGit(['status', '-z'])
177 /**
178 * See https://git-scm.com/docs/git-status#_short_format
179 * Entries returned in machine format are separated by a NUL character.
180 * The first letter of each entry represents current index status,
181 * and second the working tree. Index and working tree status codes are
182 * separated from the file name by a space. If an entry includes a
183 * renamed file, the file names are separated by a NUL character
184 * (e.g. `to`\0`from`)
185 */
186 const partiallyStaged = status
187 // eslint-disable-next-line no-control-regex
188 .split(/\x00(?=[ AMDRCU?!]{2} |$)/)
189 .filter((line) => {
190 const [index, workingTree] = line
191 return index !== ' ' && workingTree !== ' ' && index !== '?' && workingTree !== '?'
192 })
193 .map((line) => line.substr(3)) // Remove first three letters (index, workingTree, and a whitespace)
194 .filter(Boolean) // Filter empty string
195 debugLog('Found partially staged files:', partiallyStaged)
196 return partiallyStaged.length ? partiallyStaged : null
197 }
198
199 /**
200 * Create a diff of partially staged files and backup stash if enabled.
201 */
202 async prepare(ctx) {
203 try {
204 debugLog('Backing up original state...')
205
206 // Get a list of files with bot staged and unstaged changes.
207 // Unstaged changes to these files should be hidden before the tasks run.
208 this.partiallyStagedFiles = await this.getPartiallyStagedFiles()
209
210 if (this.partiallyStagedFiles) {
211 ctx.hasPartiallyStagedFiles = true
212 const unstagedPatch = this.getHiddenFilepath(PATCH_UNSTAGED)
213 const files = processRenames(this.partiallyStagedFiles)
214 await this.execGit(['diff', ...GIT_DIFF_ARGS, '--output', unstagedPatch, '--', ...files])
215 } else {
216 ctx.hasPartiallyStagedFiles = false
217 }
218
219 /**
220 * If backup stash should be skipped, no need to continue
221 */
222 if (!ctx.shouldBackup) return
223
224 // When backup is enabled, the revert will clear ongoing merge status.
225 await this.backupMergeStatus()
226
227 // Get a list of unstaged deleted files, because certain bugs might cause them to reappear:
228 // - in git versions =< 2.13.0 the `git stash --keep-index` option resurrects deleted files
229 // - git stash can't infer RD or MD states correctly, and will lose the deletion
230 this.deletedFiles = await this.getDeletedFiles()
231
232 // Save stash of all staged files.
233 // The `stash create` command creates a dangling commit without removing any files,
234 // and `stash store` saves it as an actual stash.
235 const hash = await this.execGit(['stash', 'create'])
236 await this.execGit(['stash', 'store', '--quiet', '--message', STASH, hash])
237
238 debugLog('Done backing up original state!')
239 } catch (error) {
240 handleError(error, ctx)
241 }
242 }
243
244 /**
245 * Remove unstaged changes to all partially staged files, to avoid tasks from seeing them
246 */
247 async hideUnstagedChanges(ctx) {
248 try {
249 const files = processRenames(this.partiallyStagedFiles, false)
250 await this.execGit(['checkout', '--force', '--', ...files])
251 } catch (error) {
252 /**
253 * `git checkout --force` doesn't throw errors, so it shouldn't be possible to get here.
254 * If this does fail, the handleError method will set ctx.gitError and lint-staged will fail.
255 */
256 handleError(error, ctx, HideUnstagedChangesError)
257 }
258 }
259
260 /**
261 * Applies back task modifications, and unstaged changes hidden in the stash.
262 * In case of a merge-conflict retry with 3-way merge.
263 */
264 async applyModifications(ctx) {
265 debugLog('Adding task modifications to index...')
266
267 // `matchedFileChunks` includes staged files that lint-staged originally detected and matched against a task.
268 // Add only these files so any 3rd-party edits to other files won't be included in the commit.
269 // These additions per chunk are run "serially" to prevent race conditions.
270 // Git add creates a lockfile in the repo causing concurrent operations to fail.
271 for (const files of this.matchedFileChunks) {
272 await this.execGit(['add', '--', ...files])
273 }
274
275 debugLog('Done adding task modifications to index!')
276
277 const stagedFilesAfterAdd = await this.execGit(getDiffCommand(this.diff, this.diffFilter))
278 if (!stagedFilesAfterAdd && !this.allowEmpty) {
279 // Tasks reverted all staged changes and the commit would be empty
280 // Throw error to stop commit unless `--allow-empty` was used
281 handleError(new Error('Prevented an empty git commit!'), ctx, ApplyEmptyCommitError)
282 }
283 }
284
285 /**
286 * Restore unstaged changes to partially changed files. If it at first fails,
287 * this is probably because of conflicts between new task modifications.
288 * 3-way merge usually fixes this, and in case it doesn't we should just give up and throw.
289 */
290 async restoreUnstagedChanges(ctx) {
291 debugLog('Restoring unstaged changes...')
292 const unstagedPatch = this.getHiddenFilepath(PATCH_UNSTAGED)
293 try {
294 await this.execGit(['apply', ...GIT_APPLY_ARGS, unstagedPatch])
295 } catch (applyError) {
296 debugLog('Error while restoring changes:')
297 debugLog(applyError)
298 debugLog('Retrying with 3-way merge')
299 try {
300 // Retry with a 3-way merge if normal apply fails
301 await this.execGit(['apply', ...GIT_APPLY_ARGS, '--3way', unstagedPatch])
302 } catch (threeWayApplyError) {
303 debugLog('Error while restoring unstaged changes using 3-way merge:')
304 debugLog(threeWayApplyError)
305 handleError(
306 new Error('Unstaged changes could not be restored due to a merge conflict!'),
307 ctx,
308 RestoreUnstagedChangesError
309 )
310 }
311 }
312 }
313
314 /**
315 * Restore original HEAD state in case of errors
316 */
317 async restoreOriginalState(ctx) {
318 try {
319 debugLog('Restoring original state...')
320 await this.execGit(['reset', '--hard', 'HEAD'])
321 await this.execGit(['stash', 'apply', '--quiet', '--index', await this.getBackupStash(ctx)])
322
323 // Restore meta information about ongoing git merge
324 await this.restoreMergeStatus(ctx)
325
326 // If stashing resurrected deleted files, clean them out
327 await Promise.all(this.deletedFiles.map((file) => unlink(file)))
328
329 // Clean out patch
330 await unlink(this.getHiddenFilepath(PATCH_UNSTAGED))
331
332 debugLog('Done restoring original state!')
333 } catch (error) {
334 handleError(error, ctx, RestoreOriginalStateError)
335 }
336 }
337
338 /**
339 * Drop the created stashes after everything has run
340 */
341 async cleanup(ctx) {
342 try {
343 debugLog('Dropping backup stash...')
344 await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)])
345 debugLog('Done dropping backup stash!')
346 } catch (error) {
347 handleError(error, ctx)
348 }
349 }
350}