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