UNPKG

10.2 kBJavaScriptView Raw
1'use strict'
2
3const debug = require('debug')('lint-staged:git')
4const path = require('path')
5
6const execGit = require('./execGit')
7const { exists, readFile, unlink, writeFile } = require('./file')
8
9const MERGE_HEAD = 'MERGE_HEAD'
10const MERGE_MODE = 'MERGE_MODE'
11const MERGE_MSG = 'MERGE_MSG'
12
13const STASH = 'lint-staged automatic backup'
14
15const PATCH_UNSTAGED = 'lint-staged_unstaged.patch'
16const PATCH_UNTRACKED = 'lint-staged_untracked.patch'
17
18const GIT_APPLY_ARGS = ['apply', '-v', '--whitespace=nowarn', '--recount', '--unidiff-zero']
19const GIT_DIFF_ARGS = ['--binary', '--unified=0', '--no-color', '--no-ext-diff', '--patch']
20
21const handleError = (error, ctx) => {
22 ctx.gitError = true
23 throw error
24}
25
26class GitWorkflow {
27 constructor({ allowEmpty, gitConfigDir, gitDir, stagedFileChunks }) {
28 this.execGit = (args, options = {}) => execGit(args, { ...options, cwd: gitDir })
29 this.gitConfigDir = gitConfigDir
30 this.gitDir = gitDir
31 this.unstagedDiff = null
32 this.allowEmpty = allowEmpty
33 this.stagedFileChunks = stagedFileChunks
34
35 /**
36 * These three files hold state about an ongoing git merge
37 * Resolve paths during constructor
38 */
39 this.mergeHeadFilename = path.resolve(gitConfigDir, MERGE_HEAD)
40 this.mergeModeFilename = path.resolve(gitConfigDir, MERGE_MODE)
41 this.mergeMsgFilename = path.resolve(gitConfigDir, MERGE_MSG)
42 }
43
44 /**
45 * Get absolute path to file hidden inside .git
46 * @param {string} filename
47 */
48 getHiddenFilepath(filename) {
49 return path.resolve(this.gitConfigDir, `./${filename}`)
50 }
51
52 /**
53 * Check if patch file exists and has content.
54 * @param {string} filename
55 */
56 async hasPatch(filename) {
57 const resolved = this.getHiddenFilepath(filename)
58 const pathIfExists = await exists(resolved)
59 if (!pathIfExists) return false
60 const buffer = await readFile(pathIfExists)
61 const patch = buffer.toString().trim()
62 return patch.length ? filename : false
63 }
64
65 /**
66 * Get name of backup stash
67 */
68 async getBackupStash(ctx) {
69 const stashes = await this.execGit(['stash', 'list'])
70 const index = stashes.split('\n').findIndex(line => line.includes(STASH))
71 if (index === -1) {
72 ctx.gitGetBackupStashError = true
73 throw new Error('lint-staged automatic backup is missing!')
74 }
75 return `stash@{${index}}`
76 }
77
78 /**
79 * Save meta information about ongoing git merge
80 */
81 async backupMergeStatus() {
82 debug('Backing up merge state...')
83 await Promise.all([
84 readFile(this.mergeHeadFilename).then(buffer => (this.mergeHeadBuffer = buffer)),
85 readFile(this.mergeModeFilename).then(buffer => (this.mergeModeBuffer = buffer)),
86 readFile(this.mergeMsgFilename).then(buffer => (this.mergeMsgBuffer = buffer))
87 ])
88 debug('Done backing up merge state!')
89 }
90
91 /**
92 * Restore meta information about ongoing git merge
93 */
94 async restoreMergeStatus() {
95 debug('Restoring merge state...')
96 try {
97 await Promise.all([
98 this.mergeHeadBuffer && writeFile(this.mergeHeadFilename, this.mergeHeadBuffer),
99 this.mergeModeBuffer && writeFile(this.mergeModeFilename, this.mergeModeBuffer),
100 this.mergeMsgBuffer && writeFile(this.mergeMsgFilename, this.mergeMsgBuffer)
101 ])
102 debug('Done restoring merge state!')
103 } catch (error) {
104 debug('Failed restoring merge state with error:')
105 debug(error)
106 throw new Error('Merge state could not be restored due to an error!')
107 }
108 }
109
110 /**
111 * List and delete untracked files
112 */
113 async cleanUntrackedFiles() {
114 const lsFiles = await this.execGit(['ls-files', '--others', '--exclude-standard'])
115 const untrackedFiles = lsFiles
116 .split('\n')
117 .filter(Boolean)
118 .map(file => path.resolve(this.gitDir, file))
119 await Promise.all(untrackedFiles.map(file => unlink(file)))
120 }
121
122 /**
123 * Create backup stashes, one of everything and one of only staged changes
124 * Staged files are left in the index for running tasks
125 */
126 async stashBackup(ctx) {
127 try {
128 debug('Backing up original state...')
129
130 // the `git stash` clears metadata about a possible git merge
131 // Manually check and backup if necessary
132 await this.backupMergeStatus()
133
134 // Get a list of unstaged deleted files, because certain bugs might cause them to reappear:
135 // - in git versions =< 2.13.0 the `--keep-index` flag resurrects deleted files
136 // - git stash can't infer RD or MD states correctly, and will lose the deletion
137 this.deletedFiles = (await this.execGit(['ls-files', '--deleted']))
138 .split('\n')
139 .filter(Boolean)
140 .map(file => path.resolve(this.gitDir, file))
141
142 // Save stash of entire original state, including unstaged and untracked changes.
143 // `--keep-index leaves only staged files on disk, for tasks.`
144 await this.execGit(['stash', 'save', '--include-untracked', '--keep-index', STASH])
145
146 // Restore meta information about ongoing git merge
147 await this.restoreMergeStatus()
148
149 // There is a bug in git =< 2.13.0 where `--keep-index` resurrects deleted files.
150 // These files should be listed and deleted before proceeding.
151 await this.cleanUntrackedFiles()
152
153 // Get a diff of unstaged changes by diffing the saved stash against what's left on disk.
154 await this.execGit([
155 'diff',
156 ...GIT_DIFF_ARGS,
157 `--output=${this.getHiddenFilepath(PATCH_UNSTAGED)}`,
158 await this.getBackupStash(ctx),
159 '-R' // Show diff in reverse
160 ])
161
162 debug('Done backing up original state!')
163 } catch (error) {
164 if (error.message && error.message.includes('You do not have the initial commit yet')) {
165 ctx.emptyGitRepo = true
166 }
167 handleError(error, ctx)
168 }
169 }
170
171 /**
172 * Applies back task modifications, and unstaged changes hidden in the stash.
173 * In case of a merge-conflict retry with 3-way merge.
174 */
175 async applyModifications(ctx) {
176 const modifiedFiles = await this.execGit(['ls-files', '--modified'])
177 if (modifiedFiles) {
178 debug('Detected files modified by tasks:')
179 debug(modifiedFiles)
180 debug('Adding files to index...')
181 await Promise.all(
182 // stagedFileChunks includes staged files that lint-staged originally detected.
183 // Add only these files so any 3rd-party edits to other files won't be included in the commit.
184 this.stagedFileChunks.map(stagedFiles => this.execGit(['add', ...stagedFiles]))
185 )
186 debug('Done adding files to index!')
187 }
188
189 const modifiedFilesAfterAdd = await this.execGit(['status', '--porcelain'])
190 if (!modifiedFilesAfterAdd && !this.allowEmpty) {
191 // Tasks reverted all staged changes and the commit would be empty
192 // Throw error to stop commit unless `--allow-empty` was used
193 ctx.gitApplyEmptyCommit = true
194 handleError(new Error('Prevented an empty git commit!'), ctx)
195 }
196
197 // Restore unstaged changes by applying the diff back. If it at first fails,
198 // this is probably because of conflicts between task modifications.
199 // 3-way merge usually fixes this, and in case it doesn't we should just give up and throw.
200 if (await this.hasPatch(PATCH_UNSTAGED)) {
201 debug('Restoring unstaged changes...')
202 const unstagedPatch = this.getHiddenFilepath(PATCH_UNSTAGED)
203 try {
204 await this.execGit([...GIT_APPLY_ARGS, unstagedPatch])
205 } catch (error) {
206 debug('Error while restoring changes:')
207 debug(error)
208 debug('Retrying with 3-way merge')
209
210 try {
211 // Retry with `--3way` if normal apply fails
212 await this.execGit([...GIT_APPLY_ARGS, '--3way', unstagedPatch])
213 } catch (error2) {
214 debug('Error while restoring unstaged changes using 3-way merge:')
215 debug(error2)
216 ctx.gitApplyModificationsError = true
217 handleError(
218 new Error('Unstaged changes could not be restored due to a merge conflict!'),
219 ctx
220 )
221 }
222 }
223 debug('Done restoring unstaged changes!')
224 }
225
226 // Restore untracked files by reading from the third commit associated with the backup stash
227 // See https://stackoverflow.com/a/52357762
228 try {
229 const backupStash = await this.getBackupStash(ctx)
230 const untrackedPatch = this.getHiddenFilepath(PATCH_UNTRACKED)
231 await this.execGit([
232 'show',
233 ...GIT_DIFF_ARGS,
234 '--format=%b',
235 `--output=${untrackedPatch}`,
236 `${backupStash}^3`
237 ])
238 if (await this.hasPatch(PATCH_UNTRACKED)) {
239 await this.execGit([...GIT_APPLY_ARGS, untrackedPatch])
240 }
241 } catch (error) {
242 ctx.gitRestoreUntrackedError = true
243 handleError(error, ctx)
244 }
245
246 // If stashing resurrected deleted files, clean them out
247 await Promise.all(this.deletedFiles.map(file => unlink(file)))
248 }
249
250 /**
251 * Restore original HEAD state in case of errors
252 */
253 async restoreOriginalState(ctx) {
254 try {
255 debug('Restoring original state...')
256 const backupStash = await this.getBackupStash(ctx)
257 await this.execGit(['reset', '--hard', 'HEAD'])
258 await this.execGit(['stash', 'apply', '--quiet', '--index', backupStash])
259 debug('Done restoring original state!')
260
261 // If stashing resurrected deleted files, clean them out
262 await Promise.all(this.deletedFiles.map(file => unlink(file)))
263
264 // Restore meta information about ongoing git merge
265 await this.restoreMergeStatus()
266 } catch (error) {
267 ctx.gitRestoreOriginalStateError = true
268 handleError(error, ctx)
269 }
270 }
271
272 /**
273 * Drop the created stashes after everything has run
274 */
275 async dropBackup(ctx) {
276 try {
277 debug('Dropping backup stash...')
278 await Promise.all([
279 exists(this.getHiddenFilepath(PATCH_UNSTAGED)).then(unlink),
280 exists(this.getHiddenFilepath(PATCH_UNTRACKED)).then(unlink)
281 ])
282 const backupStash = await this.getBackupStash(ctx)
283 await this.execGit(['stash', 'drop', '--quiet', backupStash])
284 debug('Done dropping backup stash!')
285 } catch (error) {
286 handleError(error, ctx)
287 }
288 }
289}
290
291module.exports = GitWorkflow