1 | 'use strict'
|
2 |
|
3 | const debug = require('debug')('lint-staged:git')
|
4 | const path = require('path')
|
5 |
|
6 | const execGit = require('./execGit')
|
7 | const { readFile, unlink, writeFile } = require('./file')
|
8 | const {
|
9 | GitError,
|
10 | RestoreOriginalStateError,
|
11 | ApplyEmptyCommitError,
|
12 | GetBackupStashError,
|
13 | HideUnstagedChangesError,
|
14 | RestoreMergeStatusError,
|
15 | RestoreUnstagedChangesError,
|
16 | } = require('./symbols')
|
17 |
|
18 | const MERGE_HEAD = 'MERGE_HEAD'
|
19 | const MERGE_MODE = 'MERGE_MODE'
|
20 | const MERGE_MSG = 'MERGE_MSG'
|
21 |
|
22 |
|
23 |
|
24 | const RENAME = / -> /
|
25 |
|
26 |
|
27 |
|
28 |
|
29 |
|
30 |
|
31 | const processRenames = (files, includeRenameFrom = true) =>
|
32 | files.reduce((flattened, file) => {
|
33 | if (RENAME.test(file)) {
|
34 | const [from, to] = file.split(RENAME)
|
35 | if (includeRenameFrom) flattened.push(from)
|
36 | flattened.push(to)
|
37 | } else {
|
38 | flattened.push(file)
|
39 | }
|
40 | return flattened
|
41 | }, [])
|
42 |
|
43 | const STASH = 'lint-staged automatic backup'
|
44 |
|
45 | const PATCH_UNSTAGED = 'lint-staged_unstaged.patch'
|
46 |
|
47 | const GIT_DIFF_ARGS = [
|
48 | '--binary',
|
49 | '--unified=0',
|
50 | '--no-color',
|
51 | '--no-ext-diff',
|
52 | '--src-prefix=a/',
|
53 | '--dst-prefix=b/',
|
54 | '--patch',
|
55 | ]
|
56 | const GIT_APPLY_ARGS = ['-v', '--whitespace=nowarn', '--recount', '--unidiff-zero']
|
57 |
|
58 | const handleError = (error, ctx, symbol) => {
|
59 | ctx.errors.add(GitError)
|
60 | if (symbol) ctx.errors.add(symbol)
|
61 | throw error
|
62 | }
|
63 |
|
64 | class GitWorkflow {
|
65 | constructor({ allowEmpty, gitConfigDir, gitDir, matchedFileChunks }) {
|
66 | this.execGit = (args, options = {}) => execGit(args, { ...options, cwd: gitDir })
|
67 | this.deletedFiles = []
|
68 | this.gitConfigDir = gitConfigDir
|
69 | this.gitDir = gitDir
|
70 | this.unstagedDiff = null
|
71 | this.allowEmpty = allowEmpty
|
72 | this.matchedFileChunks = matchedFileChunks
|
73 |
|
74 | |
75 |
|
76 |
|
77 |
|
78 | this.mergeHeadFilename = path.resolve(gitConfigDir, MERGE_HEAD)
|
79 | this.mergeModeFilename = path.resolve(gitConfigDir, MERGE_MODE)
|
80 | this.mergeMsgFilename = path.resolve(gitConfigDir, MERGE_MSG)
|
81 | }
|
82 |
|
83 | |
84 |
|
85 |
|
86 |
|
87 | getHiddenFilepath(filename) {
|
88 | return path.resolve(this.gitConfigDir, `./${filename}`)
|
89 | }
|
90 |
|
91 | |
92 |
|
93 |
|
94 | async getBackupStash(ctx) {
|
95 | const stashes = await this.execGit(['stash', 'list'])
|
96 | const index = stashes.split('\n').findIndex((line) => line.includes(STASH))
|
97 | if (index === -1) {
|
98 | ctx.errors.add(GetBackupStashError)
|
99 | throw new Error('lint-staged automatic backup is missing!')
|
100 | }
|
101 | return `stash@{${index}}`
|
102 | }
|
103 |
|
104 | |
105 |
|
106 |
|
107 | async getDeletedFiles() {
|
108 | debug('Getting deleted files...')
|
109 | const lsFiles = await this.execGit(['ls-files', '--deleted'])
|
110 | const deletedFiles = lsFiles
|
111 | .split('\n')
|
112 | .filter(Boolean)
|
113 | .map((file) => path.resolve(this.gitDir, file))
|
114 | debug('Found deleted files:', deletedFiles)
|
115 | return deletedFiles
|
116 | }
|
117 |
|
118 | |
119 |
|
120 |
|
121 | async backupMergeStatus() {
|
122 | debug('Backing up merge state...')
|
123 | await Promise.all([
|
124 | readFile(this.mergeHeadFilename).then((buffer) => (this.mergeHeadBuffer = buffer)),
|
125 | readFile(this.mergeModeFilename).then((buffer) => (this.mergeModeBuffer = buffer)),
|
126 | readFile(this.mergeMsgFilename).then((buffer) => (this.mergeMsgBuffer = buffer)),
|
127 | ])
|
128 | debug('Done backing up merge state!')
|
129 | }
|
130 |
|
131 | |
132 |
|
133 |
|
134 | async restoreMergeStatus(ctx) {
|
135 | debug('Restoring merge state...')
|
136 | try {
|
137 | await Promise.all([
|
138 | this.mergeHeadBuffer && writeFile(this.mergeHeadFilename, this.mergeHeadBuffer),
|
139 | this.mergeModeBuffer && writeFile(this.mergeModeFilename, this.mergeModeBuffer),
|
140 | this.mergeMsgBuffer && writeFile(this.mergeMsgFilename, this.mergeMsgBuffer),
|
141 | ])
|
142 | debug('Done restoring merge state!')
|
143 | } catch (error) {
|
144 | debug('Failed restoring merge state with error:')
|
145 | debug(error)
|
146 | handleError(
|
147 | new Error('Merge state could not be restored due to an error!'),
|
148 | ctx,
|
149 | RestoreMergeStatusError
|
150 | )
|
151 | }
|
152 | }
|
153 |
|
154 | |
155 |
|
156 |
|
157 |
|
158 |
|
159 | async getPartiallyStagedFiles() {
|
160 | debug('Getting partially staged files...')
|
161 | const status = await this.execGit(['status', '--porcelain'])
|
162 | const partiallyStaged = status
|
163 | .split('\n')
|
164 | .filter((line) => {
|
165 | |
166 |
|
167 |
|
168 |
|
169 |
|
170 | const [index, workingTree] = line
|
171 | return index !== ' ' && workingTree !== ' ' && index !== '?' && workingTree !== '?'
|
172 | })
|
173 | .map((line) => line.substr(3))
|
174 | .filter(Boolean)
|
175 | debug('Found partially staged files:', partiallyStaged)
|
176 | return partiallyStaged.length ? partiallyStaged : null
|
177 | }
|
178 |
|
179 | |
180 |
|
181 |
|
182 | async prepare(ctx) {
|
183 | try {
|
184 | debug('Backing up original state...')
|
185 |
|
186 |
|
187 |
|
188 | this.partiallyStagedFiles = await this.getPartiallyStagedFiles()
|
189 |
|
190 | if (this.partiallyStagedFiles) {
|
191 | ctx.hasPartiallyStagedFiles = true
|
192 | const unstagedPatch = this.getHiddenFilepath(PATCH_UNSTAGED)
|
193 | const files = processRenames(this.partiallyStagedFiles)
|
194 | await this.execGit(['diff', ...GIT_DIFF_ARGS, '--output', unstagedPatch, '--', ...files])
|
195 | } else {
|
196 | ctx.hasPartiallyStagedFiles = false
|
197 | }
|
198 |
|
199 | |
200 |
|
201 |
|
202 | if (!ctx.shouldBackup) return
|
203 |
|
204 |
|
205 | await this.backupMergeStatus()
|
206 |
|
207 |
|
208 |
|
209 |
|
210 | this.deletedFiles = await this.getDeletedFiles()
|
211 |
|
212 |
|
213 |
|
214 |
|
215 | const hash = await this.execGit(['stash', 'create'])
|
216 | await this.execGit(['stash', 'store', '--quiet', '--message', STASH, hash])
|
217 |
|
218 | debug('Done backing up original state!')
|
219 | } catch (error) {
|
220 | handleError(error, ctx)
|
221 | }
|
222 | }
|
223 |
|
224 | |
225 |
|
226 |
|
227 | async hideUnstagedChanges(ctx) {
|
228 | try {
|
229 | const files = processRenames(this.partiallyStagedFiles, false)
|
230 | await this.execGit(['checkout', '--force', '--', ...files])
|
231 | } catch (error) {
|
232 | |
233 |
|
234 |
|
235 |
|
236 | handleError(error, ctx, HideUnstagedChangesError)
|
237 | }
|
238 | }
|
239 |
|
240 | |
241 |
|
242 |
|
243 |
|
244 | async applyModifications(ctx) {
|
245 | debug('Adding task modifications to index...')
|
246 |
|
247 |
|
248 |
|
249 |
|
250 |
|
251 | for (const files of this.matchedFileChunks) {
|
252 | await this.execGit(['add', '--', ...files])
|
253 | }
|
254 |
|
255 | debug('Done adding task modifications to index!')
|
256 |
|
257 | const stagedFilesAfterAdd = await this.execGit(['diff', '--name-only', '--cached'])
|
258 | if (!stagedFilesAfterAdd && !this.allowEmpty) {
|
259 |
|
260 |
|
261 | handleError(new Error('Prevented an empty git commit!'), ctx, ApplyEmptyCommitError)
|
262 | }
|
263 | }
|
264 |
|
265 | |
266 |
|
267 |
|
268 |
|
269 |
|
270 | async restoreUnstagedChanges(ctx) {
|
271 | debug('Restoring unstaged changes...')
|
272 | const unstagedPatch = this.getHiddenFilepath(PATCH_UNSTAGED)
|
273 | try {
|
274 | await this.execGit(['apply', ...GIT_APPLY_ARGS, unstagedPatch])
|
275 | } catch (applyError) {
|
276 | debug('Error while restoring changes:')
|
277 | debug(applyError)
|
278 | debug('Retrying with 3-way merge')
|
279 | try {
|
280 |
|
281 | await this.execGit(['apply', ...GIT_APPLY_ARGS, '--3way', unstagedPatch])
|
282 | } catch (threeWayApplyError) {
|
283 | debug('Error while restoring unstaged changes using 3-way merge:')
|
284 | debug(threeWayApplyError)
|
285 | handleError(
|
286 | new Error('Unstaged changes could not be restored due to a merge conflict!'),
|
287 | ctx,
|
288 | RestoreUnstagedChangesError
|
289 | )
|
290 | }
|
291 | }
|
292 | }
|
293 |
|
294 | |
295 |
|
296 |
|
297 | async restoreOriginalState(ctx) {
|
298 | try {
|
299 | debug('Restoring original state...')
|
300 | await this.execGit(['reset', '--hard', 'HEAD'])
|
301 | await this.execGit(['stash', 'apply', '--quiet', '--index', await this.getBackupStash(ctx)])
|
302 |
|
303 |
|
304 | await this.restoreMergeStatus(ctx)
|
305 |
|
306 |
|
307 | await Promise.all(this.deletedFiles.map((file) => unlink(file)))
|
308 |
|
309 |
|
310 | await unlink(this.getHiddenFilepath(PATCH_UNSTAGED))
|
311 |
|
312 | debug('Done restoring original state!')
|
313 | } catch (error) {
|
314 | handleError(error, ctx, RestoreOriginalStateError)
|
315 | }
|
316 | }
|
317 |
|
318 | |
319 |
|
320 |
|
321 | async cleanup(ctx) {
|
322 | try {
|
323 | debug('Dropping backup stash...')
|
324 | await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)])
|
325 | debug('Done dropping backup stash!')
|
326 | } catch (error) {
|
327 | handleError(error, ctx)
|
328 | }
|
329 | }
|
330 | }
|
331 |
|
332 | module.exports = GitWorkflow
|