UNPKG

11.7 kBJavaScriptView Raw
1'use strict'
2
3const debug = require('debug')('lint-staged:git')
4const path = require('path')
5
6const execGit = require('./execGit')
7const { readFile, unlink, writeFile } = require('./file')
8const {
9 GitError,
10 RestoreOriginalStateError,
11 ApplyEmptyCommitError,
12 GetBackupStashError,
13 HideUnstagedChangesError,
14 RestoreMergeStatusError,
15 RestoreUnstagedChangesError,
16} = require('./symbols')
17
18const MERGE_HEAD = 'MERGE_HEAD'
19const MERGE_MODE = 'MERGE_MODE'
20const MERGE_MSG = 'MERGE_MSG'
21
22// In git status, renames are presented as `from` -> `to`.
23// When diffing, both need to be taken into account, but in some cases on the `to`.
24const RENAME = / -> /
25
26/**
27 * From list of files, split renames and flatten into two files `from` -> `to`.
28 * @param {string[]} files
29 * @param {Boolean} [includeRenameFrom=true] Whether or not to include the `from` renamed file, which is no longer on disk
30 */
31const 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
43const STASH = 'lint-staged automatic backup'
44
45const PATCH_UNSTAGED = 'lint-staged_unstaged.patch'
46
47const GIT_DIFF_ARGS = [
48 '--binary', // support binary files
49 '--unified=0', // do not add lines around diff for consistent behaviour
50 '--no-color', // disable colors for consistent behaviour
51 '--no-ext-diff', // disable external diff tools for consistent behaviour
52 '--src-prefix=a/', // force prefix for consistent behaviour
53 '--dst-prefix=b/', // force prefix for consistent behaviour
54 '--patch', // output a patch that can be applied
55]
56const GIT_APPLY_ARGS = ['-v', '--whitespace=nowarn', '--recount', '--unidiff-zero']
57
58const handleError = (error, ctx, symbol) => {
59 ctx.errors.add(GitError)
60 if (symbol) ctx.errors.add(symbol)
61 throw error
62}
63
64class 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 * These three files hold state about an ongoing git merge
76 * Resolve paths during constructor
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 * Get absolute path to file hidden inside .git
85 * @param {string} filename
86 */
87 getHiddenFilepath(filename) {
88 return path.resolve(this.gitConfigDir, `./${filename}`)
89 }
90
91 /**
92 * Get name of backup stash
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 * Get a list of unstaged deleted files
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 * Save meta information about ongoing git merge
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 * Restore meta information about ongoing git merge
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 * Get a list of all files with both staged and unstaged modifications.
156 * Renames have special treatment, since the single status line includes
157 * both the "from" and "to" filenames, where "from" is no longer on disk.
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 * See https://git-scm.com/docs/git-status#_short_format
167 * The first letter of the line represents current index status,
168 * and second the working tree
169 */
170 const [index, workingTree] = line
171 return index !== ' ' && workingTree !== ' ' && index !== '?' && workingTree !== '?'
172 })
173 .map((line) => line.substr(3)) // Remove first three letters (index, workingTree, and a whitespace)
174 .filter(Boolean) // Filter empty string
175 debug('Found partially staged files:', partiallyStaged)
176 return partiallyStaged.length ? partiallyStaged : null
177 }
178
179 /**
180 * Create a diff of partially staged files and backup stash if enabled.
181 */
182 async prepare(ctx) {
183 try {
184 debug('Backing up original state...')
185
186 // Get a list of files with bot staged and unstaged changes.
187 // Unstaged changes to these files should be hidden before the tasks run.
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 * If backup stash should be skipped, no need to continue
201 */
202 if (!ctx.shouldBackup) return
203
204 // When backup is enabled, the revert will clear ongoing merge status.
205 await this.backupMergeStatus()
206
207 // Get a list of unstaged deleted files, because certain bugs might cause them to reappear:
208 // - in git versions =< 2.13.0 the `git stash --keep-index` option resurrects deleted files
209 // - git stash can't infer RD or MD states correctly, and will lose the deletion
210 this.deletedFiles = await this.getDeletedFiles()
211
212 // Save stash of all staged files.
213 // The `stash create` command creates a dangling commit without removing any files,
214 // and `stash store` saves it as an actual stash.
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 * Remove unstaged changes to all partially staged files, to avoid tasks from seeing them
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 * `git checkout --force` doesn't throw errors, so it shouldn't be possible to get here.
234 * If this does fail, the handleError method will set ctx.gitError and lint-staged will fail.
235 */
236 handleError(error, ctx, HideUnstagedChangesError)
237 }
238 }
239
240 /**
241 * Applies back task modifications, and unstaged changes hidden in the stash.
242 * In case of a merge-conflict retry with 3-way merge.
243 */
244 async applyModifications(ctx) {
245 debug('Adding task modifications to index...')
246
247 // `matchedFileChunks` includes staged files that lint-staged originally detected and matched against a task.
248 // Add only these files so any 3rd-party edits to other files won't be included in the commit.
249 // These additions per chunk are run "serially" to prevent race conditions.
250 // Git add creates a lockfile in the repo causing concurrent operations to fail.
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 // Tasks reverted all staged changes and the commit would be empty
260 // Throw error to stop commit unless `--allow-empty` was used
261 handleError(new Error('Prevented an empty git commit!'), ctx, ApplyEmptyCommitError)
262 }
263 }
264
265 /**
266 * Restore unstaged changes to partially changed files. If it at first fails,
267 * this is probably because of conflicts between new task modifications.
268 * 3-way merge usually fixes this, and in case it doesn't we should just give up and throw.
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 // Retry with a 3-way merge if normal apply fails
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 * Restore original HEAD state in case of errors
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 // Restore meta information about ongoing git merge
304 await this.restoreMergeStatus(ctx)
305
306 // If stashing resurrected deleted files, clean them out
307 await Promise.all(this.deletedFiles.map((file) => unlink(file)))
308
309 // Clean out patch
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 * Drop the created stashes after everything has run
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
332module.exports = GitWorkflow