455c843833059e25c8f76760fe761b13298771cb8998f38d61978f96d4cabc6024208040ace08b59da18c9d380ec25a69e786c41c4614b113dd715e4445d4b 12 KB

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