UNPKG

12.2 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 machine output, renames are presented as `to`NUL`from`
23// When diffing, both need to be taken into account, but in some cases on the `to`.
24// eslint-disable-next-line no-control-regex
25const RENAME = /\x00/
26
27/**
28 * From list of files, split renames and flatten into two files `to`NUL`from`.
29 * @param {string[]} files
30 * @param {Boolean} [includeRenameFrom=true] Whether or not to include the `from` renamed file, which is no longer on disk
31 */
32const processRenames = (files, includeRenameFrom = true) =>
33 files.reduce((flattened, file) => {
34 if (RENAME.test(file)) {
35 const [to, from] = file.split(RENAME)
36 if (includeRenameFrom) flattened.push(from)
37 flattened.push(to)
38 } else {
39 flattened.push(file)
40 }
41 return flattened
42 }, [])
43
44const STASH = 'lint-staged automatic backup'
45
46const PATCH_UNSTAGED = 'lint-staged_unstaged.patch'
47
48const GIT_DIFF_ARGS = [
49 '--binary', // support binary files
50 '--unified=0', // do not add lines around diff for consistent behaviour
51 '--no-color', // disable colors for consistent behaviour
52 '--no-ext-diff', // disable external diff tools for consistent behaviour
53 '--src-prefix=a/', // force prefix for consistent behaviour
54 '--dst-prefix=b/', // force prefix for consistent behaviour
55 '--patch', // output a patch that can be applied
56 '--submodule=short', // always use the default short format for submodules
57]
58const GIT_APPLY_ARGS = ['-v', '--whitespace=nowarn', '--recount', '--unidiff-zero']
59
60const handleError = (error, ctx, symbol) => {
61 ctx.errors.add(GitError)
62 if (symbol) ctx.errors.add(symbol)
63 throw error
64}
65
66class GitWorkflow {
67 constructor({ allowEmpty, gitConfigDir, gitDir, matchedFileChunks }) {
68 this.execGit = (args, options = {}) => execGit(args, { ...options, cwd: gitDir })
69 this.deletedFiles = []
70 this.gitConfigDir = gitConfigDir
71 this.gitDir = gitDir
72 this.unstagedDiff = null
73 this.allowEmpty = allowEmpty
74 this.matchedFileChunks = matchedFileChunks
75
76 /**
77 * These three files hold state about an ongoing git merge
78 * Resolve paths during constructor
79 */
80 this.mergeHeadFilename = path.resolve(gitConfigDir, MERGE_HEAD)
81 this.mergeModeFilename = path.resolve(gitConfigDir, MERGE_MODE)
82 this.mergeMsgFilename = path.resolve(gitConfigDir, MERGE_MSG)
83 }
84
85 /**
86 * Get absolute path to file hidden inside .git
87 * @param {string} filename
88 */
89 getHiddenFilepath(filename) {
90 return path.resolve(this.gitConfigDir, `./${filename}`)
91 }
92
93 /**
94 * Get name of backup stash
95 */
96 async getBackupStash(ctx) {
97 const stashes = await this.execGit(['stash', 'list'])
98 const index = stashes.split('\n').findIndex((line) => line.includes(STASH))
99 if (index === -1) {
100 ctx.errors.add(GetBackupStashError)
101 throw new Error('lint-staged automatic backup is missing!')
102 }
103 return `refs/stash@{${index}}`
104 }
105
106 /**
107 * Get a list of unstaged deleted files
108 */
109 async getDeletedFiles() {
110 debug('Getting deleted files...')
111 const lsFiles = await this.execGit(['ls-files', '--deleted'])
112 const deletedFiles = lsFiles
113 .split('\n')
114 .filter(Boolean)
115 .map((file) => path.resolve(this.gitDir, file))
116 debug('Found deleted files:', deletedFiles)
117 return deletedFiles
118 }
119
120 /**
121 * Save meta information about ongoing git merge
122 */
123 async backupMergeStatus() {
124 debug('Backing up merge state...')
125 await Promise.all([
126 readFile(this.mergeHeadFilename).then((buffer) => (this.mergeHeadBuffer = buffer)),
127 readFile(this.mergeModeFilename).then((buffer) => (this.mergeModeBuffer = buffer)),
128 readFile(this.mergeMsgFilename).then((buffer) => (this.mergeMsgBuffer = buffer)),
129 ])
130 debug('Done backing up merge state!')
131 }
132
133 /**
134 * Restore meta information about ongoing git merge
135 */
136 async restoreMergeStatus(ctx) {
137 debug('Restoring merge state...')
138 try {
139 await Promise.all([
140 this.mergeHeadBuffer && writeFile(this.mergeHeadFilename, this.mergeHeadBuffer),
141 this.mergeModeBuffer && writeFile(this.mergeModeFilename, this.mergeModeBuffer),
142 this.mergeMsgBuffer && writeFile(this.mergeMsgFilename, this.mergeMsgBuffer),
143 ])
144 debug('Done restoring merge state!')
145 } catch (error) {
146 debug('Failed restoring merge state with error:')
147 debug(error)
148 handleError(
149 new Error('Merge state could not be restored due to an error!'),
150 ctx,
151 RestoreMergeStatusError
152 )
153 }
154 }
155
156 /**
157 * Get a list of all files with both staged and unstaged modifications.
158 * Renames have special treatment, since the single status line includes
159 * both the "from" and "to" filenames, where "from" is no longer on disk.
160 */
161 async getPartiallyStagedFiles() {
162 debug('Getting partially staged files...')
163 const status = await this.execGit(['status', '-z'])
164 /**
165 * See https://git-scm.com/docs/git-status#_short_format
166 * Entries returned in machine format are separated by a NUL character.
167 * The first letter of each entry represents current index status,
168 * and second the working tree. Index and working tree status codes are
169 * separated from the file name by a space. If an entry includes a
170 * renamed file, the file names are separated by a NUL character
171 * (e.g. `to`\0`from`)
172 */
173 const partiallyStaged = status
174 // eslint-disable-next-line no-control-regex
175 .split(/\x00(?=[ AMDRCU?!]{2} |$)/)
176 .filter((line) => {
177 const [index, workingTree] = line
178 return index !== ' ' && workingTree !== ' ' && index !== '?' && workingTree !== '?'
179 })
180 .map((line) => line.substr(3)) // Remove first three letters (index, workingTree, and a whitespace)
181 .filter(Boolean) // Filter empty string
182 debug('Found partially staged files:', partiallyStaged)
183 return partiallyStaged.length ? partiallyStaged : null
184 }
185
186 /**
187 * Create a diff of partially staged files and backup stash if enabled.
188 */
189 async prepare(ctx) {
190 try {
191 debug('Backing up original state...')
192
193 // Get a list of files with bot staged and unstaged changes.
194 // Unstaged changes to these files should be hidden before the tasks run.
195 this.partiallyStagedFiles = await this.getPartiallyStagedFiles()
196
197 if (this.partiallyStagedFiles) {
198 ctx.hasPartiallyStagedFiles = true
199 const unstagedPatch = this.getHiddenFilepath(PATCH_UNSTAGED)
200 const files = processRenames(this.partiallyStagedFiles)
201 await this.execGit(['diff', ...GIT_DIFF_ARGS, '--output', unstagedPatch, '--', ...files])
202 } else {
203 ctx.hasPartiallyStagedFiles = false
204 }
205
206 /**
207 * If backup stash should be skipped, no need to continue
208 */
209 if (!ctx.shouldBackup) return
210
211 // When backup is enabled, the revert will clear ongoing merge status.
212 await this.backupMergeStatus()
213
214 // Get a list of unstaged deleted files, because certain bugs might cause them to reappear:
215 // - in git versions =< 2.13.0 the `git stash --keep-index` option resurrects deleted files
216 // - git stash can't infer RD or MD states correctly, and will lose the deletion
217 this.deletedFiles = await this.getDeletedFiles()
218
219 // Save stash of all staged files.
220 // The `stash create` command creates a dangling commit without removing any files,
221 // and `stash store` saves it as an actual stash.
222 const hash = await this.execGit(['stash', 'create'])
223 await this.execGit(['stash', 'store', '--quiet', '--message', STASH, hash])
224
225 debug('Done backing up original state!')
226 } catch (error) {
227 handleError(error, ctx)
228 }
229 }
230
231 /**
232 * Remove unstaged changes to all partially staged files, to avoid tasks from seeing them
233 */
234 async hideUnstagedChanges(ctx) {
235 try {
236 const files = processRenames(this.partiallyStagedFiles, false)
237 await this.execGit(['checkout', '--force', '--', ...files])
238 } catch (error) {
239 /**
240 * `git checkout --force` doesn't throw errors, so it shouldn't be possible to get here.
241 * If this does fail, the handleError method will set ctx.gitError and lint-staged will fail.
242 */
243 handleError(error, ctx, HideUnstagedChangesError)
244 }
245 }
246
247 /**
248 * Applies back task modifications, and unstaged changes hidden in the stash.
249 * In case of a merge-conflict retry with 3-way merge.
250 */
251 async applyModifications(ctx) {
252 debug('Adding task modifications to index...')
253
254 // `matchedFileChunks` includes staged files that lint-staged originally detected and matched against a task.
255 // Add only these files so any 3rd-party edits to other files won't be included in the commit.
256 // These additions per chunk are run "serially" to prevent race conditions.
257 // Git add creates a lockfile in the repo causing concurrent operations to fail.
258 for (const files of this.matchedFileChunks) {
259 await this.execGit(['add', '--', ...files])
260 }
261
262 debug('Done adding task modifications to index!')
263
264 const stagedFilesAfterAdd = await this.execGit(['diff', '--name-only', '--cached'])
265 if (!stagedFilesAfterAdd && !this.allowEmpty) {
266 // Tasks reverted all staged changes and the commit would be empty
267 // Throw error to stop commit unless `--allow-empty` was used
268 handleError(new Error('Prevented an empty git commit!'), ctx, ApplyEmptyCommitError)
269 }
270 }
271
272 /**
273 * Restore unstaged changes to partially changed files. If it at first fails,
274 * this is probably because of conflicts between new task modifications.
275 * 3-way merge usually fixes this, and in case it doesn't we should just give up and throw.
276 */
277 async restoreUnstagedChanges(ctx) {
278 debug('Restoring unstaged changes...')
279 const unstagedPatch = this.getHiddenFilepath(PATCH_UNSTAGED)
280 try {
281 await this.execGit(['apply', ...GIT_APPLY_ARGS, unstagedPatch])
282 } catch (applyError) {
283 debug('Error while restoring changes:')
284 debug(applyError)
285 debug('Retrying with 3-way merge')
286 try {
287 // Retry with a 3-way merge if normal apply fails
288 await this.execGit(['apply', ...GIT_APPLY_ARGS, '--3way', unstagedPatch])
289 } catch (threeWayApplyError) {
290 debug('Error while restoring unstaged changes using 3-way merge:')
291 debug(threeWayApplyError)
292 handleError(
293 new Error('Unstaged changes could not be restored due to a merge conflict!'),
294 ctx,
295 RestoreUnstagedChangesError
296 )
297 }
298 }
299 }
300
301 /**
302 * Restore original HEAD state in case of errors
303 */
304 async restoreOriginalState(ctx) {
305 try {
306 debug('Restoring original state...')
307 await this.execGit(['reset', '--hard', 'HEAD'])
308 await this.execGit(['stash', 'apply', '--quiet', '--index', await this.getBackupStash(ctx)])
309
310 // Restore meta information about ongoing git merge
311 await this.restoreMergeStatus(ctx)
312
313 // If stashing resurrected deleted files, clean them out
314 await Promise.all(this.deletedFiles.map((file) => unlink(file)))
315
316 // Clean out patch
317 await unlink(this.getHiddenFilepath(PATCH_UNSTAGED))
318
319 debug('Done restoring original state!')
320 } catch (error) {
321 handleError(error, ctx, RestoreOriginalStateError)
322 }
323 }
324
325 /**
326 * Drop the created stashes after everything has run
327 */
328 async cleanup(ctx) {
329 try {
330 debug('Dropping backup stash...')
331 await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)])
332 debug('Done dropping backup stash!')
333 } catch (error) {
334 handleError(error, ctx)
335 }
336 }
337}
338
339module.exports = GitWorkflow