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