UNPKG

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