1 | import path from 'node:path'
|
2 |
|
3 | import debug from 'debug'
|
4 |
|
5 | import { execGit } from './execGit.js'
|
6 | import { readFile, unlink, writeFile } from './file.js'
|
7 | import { getDiffCommand } from './getDiffCommand.js'
|
8 | import {
|
9 | GitError,
|
10 | RestoreOriginalStateError,
|
11 | ApplyEmptyCommitError,
|
12 | GetBackupStashError,
|
13 | HideUnstagedChangesError,
|
14 | RestoreMergeStatusError,
|
15 | RestoreUnstagedChangesError,
|
16 | } from './symbols.js'
|
17 |
|
18 | const debugLog = debug('lint-staged:GitWorkflow')
|
19 |
|
20 | const MERGE_HEAD = 'MERGE_HEAD'
|
21 | const MERGE_MODE = 'MERGE_MODE'
|
22 | const MERGE_MSG = 'MERGE_MSG'
|
23 |
|
24 |
|
25 |
|
26 |
|
27 | const RENAME = /\x00/
|
28 |
|
29 |
|
30 |
|
31 |
|
32 |
|
33 |
|
34 | const 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 |
|
46 | export const STASH = 'lint-staged automatic backup'
|
47 |
|
48 | const PATCH_UNSTAGED = 'lint-staged_unstaged.patch'
|
49 |
|
50 | const GIT_DIFF_ARGS = [
|
51 | '--binary',
|
52 | '--unified=0',
|
53 | '--no-color',
|
54 | '--no-ext-diff',
|
55 | '--src-prefix=a/',
|
56 | '--dst-prefix=b/',
|
57 | '--patch',
|
58 | '--submodule=short',
|
59 | ]
|
60 | const GIT_APPLY_ARGS = ['-v', '--whitespace=nowarn', '--recount', '--unidiff-zero']
|
61 |
|
62 | const handleError = (error, ctx, symbol) => {
|
63 | ctx.errors.add(GitError)
|
64 | if (symbol) ctx.errors.add(symbol)
|
65 | throw error
|
66 | }
|
67 |
|
68 | export 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 |
|
81 |
|
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 |
|
90 |
|
91 |
|
92 | getHiddenFilepath(filename) {
|
93 | return path.resolve(this.gitConfigDir, `./${filename}`)
|
94 | }
|
95 |
|
96 | |
97 |
|
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 |
|
109 |
|
110 |
|
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 |
|
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 |
|
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 |
|
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 |
|
171 |
|
172 |
|
173 |
|
174 | async getPartiallyStagedFiles() {
|
175 | debugLog('Getting partially staged files...')
|
176 | const status = await this.execGit(['status', '-z'])
|
177 | |
178 |
|
179 |
|
180 |
|
181 |
|
182 |
|
183 |
|
184 |
|
185 |
|
186 | const partiallyStaged = status
|
187 |
|
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))
|
194 | .filter(Boolean)
|
195 | debugLog('Found partially staged files:', partiallyStaged)
|
196 | return partiallyStaged.length ? partiallyStaged : null
|
197 | }
|
198 |
|
199 | |
200 |
|
201 |
|
202 | async prepare(ctx) {
|
203 | try {
|
204 | debugLog('Backing up original state...')
|
205 |
|
206 |
|
207 |
|
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 |
|
221 |
|
222 | if (!ctx.shouldBackup) return
|
223 |
|
224 |
|
225 | await this.backupMergeStatus()
|
226 |
|
227 |
|
228 |
|
229 |
|
230 | this.deletedFiles = await this.getDeletedFiles()
|
231 |
|
232 |
|
233 |
|
234 |
|
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 |
|
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 |
|
254 |
|
255 |
|
256 | handleError(error, ctx, HideUnstagedChangesError)
|
257 | }
|
258 | }
|
259 |
|
260 | |
261 |
|
262 |
|
263 |
|
264 | async applyModifications(ctx) {
|
265 | debugLog('Adding task modifications to index...')
|
266 |
|
267 |
|
268 |
|
269 |
|
270 |
|
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 |
|
280 |
|
281 | handleError(new Error('Prevented an empty git commit!'), ctx, ApplyEmptyCommitError)
|
282 | }
|
283 | }
|
284 |
|
285 | |
286 |
|
287 |
|
288 |
|
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 |
|
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 |
|
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 |
|
324 | await this.restoreMergeStatus(ctx)
|
325 |
|
326 |
|
327 | await Promise.all(this.deletedFiles.map((file) => unlink(file)))
|
328 |
|
329 |
|
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 |
|
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 | }
|