UNPKG

12.3 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
46const 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 return `refs/stash@{${index}}`
107 }
108
109 /**
110 * Get a list of unstaged deleted files
111 */
112 async getDeletedFiles() {
113 debugLog('Getting deleted files...')
114 const lsFiles = await this.execGit(['ls-files', '--deleted'])
115 const deletedFiles = lsFiles
116 .split('\n')
117 .filter(Boolean)
118 .map((file) => path.resolve(this.gitDir, file))
119 debugLog('Found deleted files:', deletedFiles)
120 return deletedFiles
121 }
122
123 /**
124 * Save meta information about ongoing git merge
125 */
126 async backupMergeStatus() {
127 debugLog('Backing up merge state...')
128 await Promise.all([
129 readFile(this.mergeHeadFilename).then((buffer) => (this.mergeHeadBuffer = buffer)),
130 readFile(this.mergeModeFilename).then((buffer) => (this.mergeModeBuffer = buffer)),
131 readFile(this.mergeMsgFilename).then((buffer) => (this.mergeMsgBuffer = buffer)),
132 ])
133 debugLog('Done backing up merge state!')
134 }
135
136 /**
137 * Restore meta information about ongoing git merge
138 */
139 async restoreMergeStatus(ctx) {
140 debugLog('Restoring merge state...')
141 try {
142 await Promise.all([
143 this.mergeHeadBuffer && writeFile(this.mergeHeadFilename, this.mergeHeadBuffer),
144 this.mergeModeBuffer && writeFile(this.mergeModeFilename, this.mergeModeBuffer),
145 this.mergeMsgBuffer && writeFile(this.mergeMsgFilename, this.mergeMsgBuffer),
146 ])
147 debugLog('Done restoring merge state!')
148 } catch (error) {
149 debugLog('Failed restoring merge state with error:')
150 debugLog(error)
151 handleError(
152 new Error('Merge state could not be restored due to an error!'),
153 ctx,
154 RestoreMergeStatusError
155 )
156 }
157 }
158
159 /**
160 * Get a list of all files with both staged and unstaged modifications.
161 * Renames have special treatment, since the single status line includes
162 * both the "from" and "to" filenames, where "from" is no longer on disk.
163 */
164 async getPartiallyStagedFiles() {
165 debugLog('Getting partially staged files...')
166 const status = await this.execGit(['status', '-z'])
167 /**
168 * See https://git-scm.com/docs/git-status#_short_format
169 * Entries returned in machine format are separated by a NUL character.
170 * The first letter of each entry represents current index status,
171 * and second the working tree. Index and working tree status codes are
172 * separated from the file name by a space. If an entry includes a
173 * renamed file, the file names are separated by a NUL character
174 * (e.g. `to`\0`from`)
175 */
176 const partiallyStaged = status
177 // eslint-disable-next-line no-control-regex
178 .split(/\x00(?=[ AMDRCU?!]{2} |$)/)
179 .filter((line) => {
180 const [index, workingTree] = line
181 return index !== ' ' && workingTree !== ' ' && index !== '?' && workingTree !== '?'
182 })
183 .map((line) => line.substr(3)) // Remove first three letters (index, workingTree, and a whitespace)
184 .filter(Boolean) // Filter empty string
185 debugLog('Found partially staged files:', partiallyStaged)
186 return partiallyStaged.length ? partiallyStaged : null
187 }
188
189 /**
190 * Create a diff of partially staged files and backup stash if enabled.
191 */
192 async prepare(ctx) {
193 try {
194 debugLog('Backing up original state...')
195
196 // Get a list of files with bot staged and unstaged changes.
197 // Unstaged changes to these files should be hidden before the tasks run.
198 this.partiallyStagedFiles = await this.getPartiallyStagedFiles()
199
200 if (this.partiallyStagedFiles) {
201 ctx.hasPartiallyStagedFiles = true
202 const unstagedPatch = this.getHiddenFilepath(PATCH_UNSTAGED)
203 const files = processRenames(this.partiallyStagedFiles)
204 await this.execGit(['diff', ...GIT_DIFF_ARGS, '--output', unstagedPatch, '--', ...files])
205 } else {
206 ctx.hasPartiallyStagedFiles = false
207 }
208
209 /**
210 * If backup stash should be skipped, no need to continue
211 */
212 if (!ctx.shouldBackup) return
213
214 // When backup is enabled, the revert will clear ongoing merge status.
215 await this.backupMergeStatus()
216
217 // Get a list of unstaged deleted files, because certain bugs might cause them to reappear:
218 // - in git versions =< 2.13.0 the `git stash --keep-index` option resurrects deleted files
219 // - git stash can't infer RD or MD states correctly, and will lose the deletion
220 this.deletedFiles = await this.getDeletedFiles()
221
222 // Save stash of all staged files.
223 // The `stash create` command creates a dangling commit without removing any files,
224 // and `stash store` saves it as an actual stash.
225 const hash = await this.execGit(['stash', 'create'])
226 await this.execGit(['stash', 'store', '--quiet', '--message', STASH, hash])
227
228 debugLog('Done backing up original state!')
229 } catch (error) {
230 handleError(error, ctx)
231 }
232 }
233
234 /**
235 * Remove unstaged changes to all partially staged files, to avoid tasks from seeing them
236 */
237 async hideUnstagedChanges(ctx) {
238 try {
239 const files = processRenames(this.partiallyStagedFiles, false)
240 await this.execGit(['checkout', '--force', '--', ...files])
241 } catch (error) {
242 /**
243 * `git checkout --force` doesn't throw errors, so it shouldn't be possible to get here.
244 * If this does fail, the handleError method will set ctx.gitError and lint-staged will fail.
245 */
246 handleError(error, ctx, HideUnstagedChangesError)
247 }
248 }
249
250 /**
251 * Applies back task modifications, and unstaged changes hidden in the stash.
252 * In case of a merge-conflict retry with 3-way merge.
253 */
254 async applyModifications(ctx) {
255 debugLog('Adding task modifications to index...')
256
257 // `matchedFileChunks` includes staged files that lint-staged originally detected and matched against a task.
258 // Add only these files so any 3rd-party edits to other files won't be included in the commit.
259 // These additions per chunk are run "serially" to prevent race conditions.
260 // Git add creates a lockfile in the repo causing concurrent operations to fail.
261 for (const files of this.matchedFileChunks) {
262 await this.execGit(['add', '--', ...files])
263 }
264
265 debugLog('Done adding task modifications to index!')
266
267 const stagedFilesAfterAdd = await this.execGit(getDiffCommand(this.diff, this.diffFilter))
268 if (!stagedFilesAfterAdd && !this.allowEmpty) {
269 // Tasks reverted all staged changes and the commit would be empty
270 // Throw error to stop commit unless `--allow-empty` was used
271 handleError(new Error('Prevented an empty git commit!'), ctx, ApplyEmptyCommitError)
272 }
273 }
274
275 /**
276 * Restore unstaged changes to partially changed files. If it at first fails,
277 * this is probably because of conflicts between new task modifications.
278 * 3-way merge usually fixes this, and in case it doesn't we should just give up and throw.
279 */
280 async restoreUnstagedChanges(ctx) {
281 debugLog('Restoring unstaged changes...')
282 const unstagedPatch = this.getHiddenFilepath(PATCH_UNSTAGED)
283 try {
284 await this.execGit(['apply', ...GIT_APPLY_ARGS, unstagedPatch])
285 } catch (applyError) {
286 debugLog('Error while restoring changes:')
287 debugLog(applyError)
288 debugLog('Retrying with 3-way merge')
289 try {
290 // Retry with a 3-way merge if normal apply fails
291 await this.execGit(['apply', ...GIT_APPLY_ARGS, '--3way', unstagedPatch])
292 } catch (threeWayApplyError) {
293 debugLog('Error while restoring unstaged changes using 3-way merge:')
294 debugLog(threeWayApplyError)
295 handleError(
296 new Error('Unstaged changes could not be restored due to a merge conflict!'),
297 ctx,
298 RestoreUnstagedChangesError
299 )
300 }
301 }
302 }
303
304 /**
305 * Restore original HEAD state in case of errors
306 */
307 async restoreOriginalState(ctx) {
308 try {
309 debugLog('Restoring original state...')
310 await this.execGit(['reset', '--hard', 'HEAD'])
311 await this.execGit(['stash', 'apply', '--quiet', '--index', await this.getBackupStash(ctx)])
312
313 // Restore meta information about ongoing git merge
314 await this.restoreMergeStatus(ctx)
315
316 // If stashing resurrected deleted files, clean them out
317 await Promise.all(this.deletedFiles.map((file) => unlink(file)))
318
319 // Clean out patch
320 await unlink(this.getHiddenFilepath(PATCH_UNSTAGED))
321
322 debugLog('Done restoring original state!')
323 } catch (error) {
324 handleError(error, ctx, RestoreOriginalStateError)
325 }
326 }
327
328 /**
329 * Drop the created stashes after everything has run
330 */
331 async cleanup(ctx) {
332 try {
333 debugLog('Dropping backup stash...')
334 await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)])
335 debugLog('Done dropping backup stash!')
336 } catch (error) {
337 handleError(error, ctx)
338 }
339 }
340}