| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339 | 
							- '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
 
 
  |