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 | 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 | return `refs/stash@{${index}}`
|
107 | }
|
108 |
|
109 | |
110 |
|
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 |
|
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 |
|
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 |
|
161 |
|
162 |
|
163 |
|
164 | async getPartiallyStagedFiles() {
|
165 | debugLog('Getting partially staged files...')
|
166 | const status = await this.execGit(['status', '-z'])
|
167 | |
168 |
|
169 |
|
170 |
|
171 |
|
172 |
|
173 |
|
174 |
|
175 |
|
176 | const partiallyStaged = status
|
177 |
|
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))
|
184 | .filter(Boolean)
|
185 | debugLog('Found partially staged files:', partiallyStaged)
|
186 | return partiallyStaged.length ? partiallyStaged : null
|
187 | }
|
188 |
|
189 | |
190 |
|
191 |
|
192 | async prepare(ctx) {
|
193 | try {
|
194 | debugLog('Backing up original state...')
|
195 |
|
196 |
|
197 |
|
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 |
|
211 |
|
212 | if (!ctx.shouldBackup) return
|
213 |
|
214 |
|
215 | await this.backupMergeStatus()
|
216 |
|
217 |
|
218 |
|
219 |
|
220 | this.deletedFiles = await this.getDeletedFiles()
|
221 |
|
222 |
|
223 |
|
224 |
|
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 |
|
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 |
|
244 |
|
245 |
|
246 | handleError(error, ctx, HideUnstagedChangesError)
|
247 | }
|
248 | }
|
249 |
|
250 | |
251 |
|
252 |
|
253 |
|
254 | async applyModifications(ctx) {
|
255 | debugLog('Adding task modifications to index...')
|
256 |
|
257 |
|
258 |
|
259 |
|
260 |
|
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 |
|
270 |
|
271 | handleError(new Error('Prevented an empty git commit!'), ctx, ApplyEmptyCommitError)
|
272 | }
|
273 | }
|
274 |
|
275 | |
276 |
|
277 |
|
278 |
|
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 |
|
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 |
|
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 |
|
314 | await this.restoreMergeStatus(ctx)
|
315 |
|
316 |
|
317 | await Promise.all(this.deletedFiles.map((file) => unlink(file)))
|
318 |
|
319 |
|
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 |
|
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 | }
|