Repository: okonet/lint-staged
Pull request #801 (2020-02-26 16:03:38)

feat: use diff/apply only with partially staged files
This PR optimizes the git workflow by only doing the complex `git diff`/`git apply` to files with both unstaged and staged changes, a bit like it was with v9.

The backup stash created in the first step is applied right back, removing the need to dig untracked changes from the stash's third commit. This also means that unstaged changes are around for the tasks (like edits to eslint configuration), fixing troubles in issue https://github.com/okonet/lint-staged/issues/794. Also, a diff of unstaged changes is saved in a simpler way by running `git diff` before stashing (without the `--index` flag it includes unstaged changes).

Any unstaged changes to files that also have staged changes are cleaned out using `git checkout <file>`. After tasks have run, the diff from unstaged changes will be applied back targeting only those files using `git apply --include=<file>`. This logic is otherwise unchanged, only it skips any files with only unstaged changes because those have already been restored from the stash before running tasks.

I also split the methods targeting partially staged files into their own Listr tasks, that are only run when there are partially staged files (adopting the same detection code from v9).

All in all, this nicely simplifies the workflow and should result in even speedier runs, since instead of applying unstaged changes via `git diff`/`git apply`, we can just use `git stash apply`, and the diff calculation still present are simpler.

Fixes https://github.com/okonet/lint-staged/pull/801
Fixes https://github.com/okonet/lint-staged/issues/794

codecov[bot]: # [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/801?src=pr&el=h1) Report
> Merging [#801](https://codecov.io/gh/okonet/lint-staged/pull/801?src=pr&el=desc) into [master](https://codecov.io/gh/okonet/lint-staged/commit/b3c2ffddf63106e05f70faf5cd289deffea67859?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/okonet/lint-staged/pull/801/graphs/tree.svg?width=650&token=LSoEgsjk4O&height=150&src=pr)](https://codecov.io/gh/okonet/lint-staged/pull/801?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           master   #801   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files          13     13           
  Lines         498    497    -1     
  Branches      102    100    -2     
=====================================
- Hits          498    497    -1
```


| [Impacted Files](https://codecov.io/gh/okonet/lint-staged/pull/801?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [lib/runAll.js](https://codecov.io/gh/okonet/lint-staged/pull/801/diff?src=pr&el=tree#diff-bGliL3J1bkFsbC5qcw==) | `100% <100%> (ø)` | :arrow_up: |
| [lib/gitWorkflow.js](https://codecov.io/gh/okonet/lint-staged/pull/801/diff?src=pr&el=tree#diff-bGliL2dpdFdvcmtmbG93Lmpz) | `100% <100%> (ø)` | :arrow_up: |
| [lib/file.js](https://codecov.io/gh/okonet/lint-staged/pull/801/diff?src=pr&el=tree#diff-bGliL2ZpbGUuanM=) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/okonet/lint-staged/pull/801?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/okonet/lint-staged/pull/801?src=pr&el=footer). Last update [b3c2ffd...bd0c3df](https://codecov.io/gh/okonet/lint-staged/pull/801?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

okonet: I think so. It’s not ideal but if you believe this is fine I’m fine this it too.

Sincerely yours,
Andrey Okonetchnikov

@okonetchnikov • okonet.ru
On 13. Mar 2020, 14:14 +0100, Iiro Jäppinen <notifications@github.com>, wrote:
> @iiroj commented on this pull request.
> In lib/gitWorkflow.js:
> >        )
> -      debug('Done adding files to index!')
> -    }
> +    } catch {} // eslint-disable-line no-empty
> @okonet Are you sure you are ok with ignoring coverage from the try/catch here: https://github.com/okonet/lint-staged/blob/simplify/lib/gitWorkflow.js#L214
> Should we just show lower-than-100% test coverage? It would be around 98.5 % without ignoring that try/catch.
> I can't come up with an actual test for this, since it would mean that another git operation would have changed the list of edited files between the backup and this step (both before running the tasks). With manual testing, git checkout -f -- <file> will error when <file> does not match any git pathspec. The only case I know of is when renaming a file from A to B; git checkout -f -- A would then fail because git now tracks B.
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub, or unsubscribe.

iiroj: Actually, I can add a mock for setting the error context. This would allow removing the `instanbul ignore` comment at least. Let me fix...
iiroj: @okonet I also went ahead and added the `--skip-backup` in https://github.com/okonet/lint-staged/pull/801/commits/b5408d63790950f42f2ebb829336fd24fe303934. What do you think?
okonet: I think it's great so people who don't like the workflow can opt-out.
okonet: Name it `backup` and set to true by default?
iiroj: @okonet I got it working in https://github.com/okonet/lint-staged/pull/801/commits/4fcec8b4b9c7f1537957489e91b08ec8e7855a1e, I think `--no-backup` is a better flag name.
iiroj: Sure, works for me.
iiroj: It seems the Travis integration is not working.
okonet: Looks like it's down ATM

Patch:
diff --git a/README.md b/README.md
index 3a0bd79..b166e5e 100644
--- a/README.md
+++ b/README.md
@@ -60,13 +60,18 @@ Usage: lint-staged [options]
 
 Options:
   -V, --version                      output the version number
-  --allow-empty                      allow empty commits when tasks undo all staged changes (default: false)
+  --allow-empty                      allow empty commits when tasks revert all staged changes
+                                     (default: false)
   -c, --config [path]                path to configuration file
   -d, --debug                        print additional debug information (default: false)
-  -p, --concurrent <parallel tasks>  the number of tasks to run concurrently, or false to run tasks serially (default: true)
+  --no-stash                         disable the backup stash, and do not revert in case of
+                                     errors
+  -p, --concurrent <parallel tasks>  the number of tasks to run concurrently, or false to run
+                                     tasks serially (default: true)
   -q, --quiet                        disable lint-staged’s own console output (default: false)
   -r, --relative                     pass relative filepaths to tasks (default: false)
-  -x, --shell                        skip parsing of tasks for better shell support (default: false)
+  -x, --shell                        skip parsing of tasks for better shell support (default:
+                                     false)
   -h, --help                         output usage information
 ```
 
@@ -79,6 +84,7 @@ Options:
   - `false`: Run all tasks serially
   - `true` (default) : _Infinite_ concurrency. Runs as many tasks in parallel as possible.
   - `{number}`: Run the specified number of tasks in parallel, where `1` is equivalent to `false`.
+- **`--no-stash`**: By default a backup stash will be created before running the tasks, and all task modifications will be reverted in case of an error. This option will disable creating the stash, and instead leave all modifications in the index when aborting the commit.
 - **`--quiet`**: Supress all CLI output, except from tasks.
 - **`--relative`**: Pass filepaths relative to `process.cwd()` (where `lint-staged` runs) to tasks. Default is `false`.
 - **`--shell`**: By default linter commands will be parsed for speed and security. This has the side-effect that regular shell scripts might not work as expected. You can skip parsing of commands with this option.
@@ -168,7 +174,7 @@ Pass arguments to your commands separated by space as you would do in the shell.
 
 ## Running multiple commands in a sequence
 
-You can run multiple commands in a sequence on every glob. To do so, pass an array of commands instead of a single one. This is useful for running autoformatting tools like `eslint --fix` or `stylefmt` but can be used for any arbitrary sequences. 
+You can run multiple commands in a sequence on every glob. To do so, pass an array of commands instead of a single one. This is useful for running autoformatting tools like `eslint --fix` or `stylefmt` but can be used for any arbitrary sequences.
 
 For example:
 
diff --git a/bin/lint-staged.js b/bin/lint-staged.js
index aadfe16..b36f38a 100755
--- a/bin/lint-staged.js
+++ b/bin/lint-staged.js
@@ -31,6 +31,7 @@ cmdline
   .option('--allow-empty', 'allow empty commits when tasks revert all staged changes', false)
   .option('-c, --config [path]', 'path to configuration file')
   .option('-d, --debug', 'print additional debug information', false)
+  .option('--no-stash', 'disable the backup stash, and do not revert in case of errors', false)
   .option(
     '-p, --concurrent <parallel tasks>',
     'the number of tasks to run concurrently, or false to run tasks serially',
@@ -71,6 +72,7 @@ const options = {
   configPath: cmdline.config,
   debug: !!cmdline.debug,
   maxArgLength: getMaxArgLength() / 2,
+  stash: !!cmdline.stash, // commander inverts `no-<x>` flags to `!x`
   quiet: !!cmdline.quiet,
   relative: !!cmdline.relative,
   shell: !!cmdline.shell
diff --git a/lib/file.js b/lib/file.js
index 08d6b0c..de6e79b 100644
--- a/lib/file.js
+++ b/lib/file.js
@@ -4,25 +4,10 @@ const debug = require('debug')('lint-staged:file')
 const fs = require('fs')
 const { promisify } = require('util')
 
-const fsAccess = promisify(fs.access)
 const fsReadFile = promisify(fs.readFile)
 const fsUnlink = promisify(fs.unlink)
 const fsWriteFile = promisify(fs.writeFile)
 
-/**
- * Check if a file exists. Returns the filename if exists.
- * @param {String} filename
- * @returns {String|Boolean}
- */
-const exists = async filename => {
-  try {
-    await fsAccess(filename)
-    return filename
-  } catch {
-    return false
-  }
-}
-
 /**
  * Read contents of a file to buffer
  * @param {String} filename
@@ -44,21 +29,19 @@ const readFile = async (filename, ignoreENOENT = true) => {
 }
 
 /**
- * Unlink a file if it exists
+ * Remove a file
  * @param {String} filename
  * @param {Boolean} [ignoreENOENT=true] — Whether to throw if the file doesn't exist
  */
 const unlink = async (filename, ignoreENOENT = true) => {
-  if (filename) {
-    debug('Unlinking file `%s`', filename)
-    try {
-      await fsUnlink(filename)
-    } catch (error) {
-      if (ignoreENOENT && error.code === 'ENOENT') {
-        debug("File `%s` doesn't exist, ignoring...", filename)
-      } else {
-        throw error
-      }
+  debug('Removing file `%s`', filename)
+  try {
+    await fsUnlink(filename)
+  } catch (error) {
+    if (ignoreENOENT && error.code === 'ENOENT') {
+      debug("File `%s` doesn't exist, ignoring...", filename)
+    } else {
+      throw error
     }
   }
 }
@@ -74,7 +57,6 @@ const writeFile = async (filename, buffer) => {
 }
 
 module.exports = {
-  exists,
   readFile,
   unlink,
   writeFile
diff --git a/lib/gitWorkflow.js b/lib/gitWorkflow.js
index 2b39aca..2540548 100644
--- a/lib/gitWorkflow.js
+++ b/lib/gitWorkflow.js
@@ -4,19 +4,47 @@ const debug = require('debug')('lint-staged:git')
 const path = require('path')
 
 const execGit = require('./execGit')
-const { exists, readFile, unlink, writeFile } = require('./file')
+const { readFile, unlink, writeFile } = require('./file')
 
 const MERGE_HEAD = 'MERGE_HEAD'
 const MERGE_MODE = 'MERGE_MODE'
 const MERGE_MSG = 'MERGE_MSG'
 
+// In git status, renames are presented as `from` -> `to`.
+// When diffing, both need to be taken into account, but in some cases on the `to`.
+const RENAME = / -> /
+
+/**
+ * From list of files, split renames and flatten into two files `from` -> `to`.
+ * @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 [from, to] = 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 PATCH_UNTRACKED = 'lint-staged_untracked.patch'
 
-const GIT_APPLY_ARGS = ['apply', '-v', '--whitespace=nowarn', '--recount', '--unidiff-zero']
-const GIT_DIFF_ARGS = ['--binary', '--unified=0', '--no-color', '--no-ext-diff', '--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
+]
+const GIT_APPLY_ARGS = ['-v', '--whitespace=nowarn', '--recount', '--unidiff-zero']
 
 const handleError = (error, ctx) => {
   ctx.gitError = true
@@ -26,6 +54,7 @@ const handleError = (error, ctx) => {
 class GitWorkflow {
   constructor({ allowEmpty, gitConfigDir, gitDir, stagedFileChunks }) {
     this.execGit = (args, options = {}) => execGit(args, { ...options, cwd: gitDir })
+    this.deletedFiles = []
     this.gitConfigDir = gitConfigDir
     this.gitDir = gitDir
     this.unstagedDiff = null
@@ -49,19 +78,6 @@ class GitWorkflow {
     return path.resolve(this.gitConfigDir, `./${filename}`)
   }
 
-  /**
-   * Check if patch file exists and has content.
-   * @param {string} filename
-   */
-  async hasPatch(filename) {
-    const resolved = this.getHiddenFilepath(filename)
-    const pathIfExists = await exists(resolved)
-    if (!pathIfExists) return false
-    const buffer = await readFile(pathIfExists)
-    const patch = buffer.toString().trim()
-    return patch.length ? filename : false
-  }
-
   /**
    * Get name of backup stash
    */
@@ -75,6 +91,20 @@ class GitWorkflow {
     return `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
    */
@@ -108,56 +138,70 @@ class GitWorkflow {
   }
 
   /**
-   * List and delete untracked files
+   * 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 cleanUntrackedFiles() {
-    const lsFiles = await this.execGit(['ls-files', '--others', '--exclude-standard'])
-    const untrackedFiles = lsFiles
+  async getPartiallyStagedFiles() {
+    debug('Getting partially staged files...')
+    const status = await this.execGit(['status', '--porcelain'])
+    const partiallyStaged = status
       .split('\n')
-      .filter(Boolean)
-      .map(file => path.resolve(this.gitDir, file))
-    await Promise.all(untrackedFiles.map(file => unlink(file)))
+      .filter(line => {
+        /**
+         * See https://git-scm.com/docs/git-status#_short_format
+         * The first letter of the line represents current index status,
+         * and second the working tree
+         */
+        const [index, workingTree] = line
+        return index !== ' ' && workingTree !== ' ' && index !== '?' && workingTree !== '?'
+      })
+      .map(line => line.substr(3)) // Remove first three letters (index, workingTree, and a whitespace)
+    debug('Found partially staged files:', partiallyStaged)
+    return partiallyStaged.length ? partiallyStaged : null
   }
 
   /**
-   * Create backup stashes, one of everything and one of only staged changes
-   * Staged files are left in the index for running tasks
+   * Create a diff of partially staged files and backup stash if enabled.
    */
-  async stashBackup(ctx) {
+  async prepare(ctx, stash) {
     try {
       debug('Backing up original state...')
 
-      // the `git stash` clears metadata about a possible git merge
-      // Manually check and backup if necessary
-      await this.backupMergeStatus()
+      // 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])
+      }
+
+      /**
+       * If backup stash should be skipped, no need to continue
+       */
+      if (!stash) return
 
       // Get a list of unstaged deleted files, because certain bugs might cause them to reappear:
       // - in git versions =< 2.13.0 the `--keep-index` flag resurrects deleted files
       // - git stash can't infer RD or MD states correctly, and will lose the deletion
-      this.deletedFiles = (await this.execGit(['ls-files', '--deleted']))
-        .split('\n')
-        .filter(Boolean)
-        .map(file => path.resolve(this.gitDir, file))
+      this.deletedFiles = await this.getDeletedFiles()
 
-      // Save stash of entire original state, including unstaged and untracked changes.
-      // `--keep-index leaves only staged files on disk, for tasks.`
-      await this.execGit(['stash', 'save', '--include-untracked', '--keep-index', STASH])
+      // the `git stash` clears metadata about a possible git merge
+      // Manually check and backup if necessary
+      await this.backupMergeStatus()
 
-      // Restore meta information about ongoing git merge
+      // Save stash of entire original state, including unstaged and untracked changes
+      await this.execGit(['stash', 'save', '--include-untracked', STASH])
+      await this.execGit(['stash', 'apply', '--quiet', '--index', await this.getBackupStash()])
+
+      // Restore meta information about ongoing git merge, cleared by `git stash`
       await this.restoreMergeStatus()
 
-      // There is a bug in git =< 2.13.0 where `--keep-index` resurrects deleted files.
-      // These files should be listed and deleted before proceeding.
-      await this.cleanUntrackedFiles()
-
-      // Get a diff of unstaged changes by diffing the saved stash against what's left on disk.
-      await this.execGit([
-        'diff',
-        ...GIT_DIFF_ARGS,
-        `--output=${this.getHiddenFilepath(PATCH_UNSTAGED)}`,
-        await this.getBackupStash(ctx),
-        '-R' // Show diff in reverse
-      ])
+      // If stashing resurrected deleted files, clean them out
+      await Promise.all(this.deletedFiles.map(file => unlink(file)))
 
       debug('Done backing up original state!')
     } catch (error) {
@@ -168,83 +212,72 @@ class GitWorkflow {
     }
   }
 
+  /**
+   * 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.
+       */
+      ctx.gitHideUnstagedChangesError = true
+      handleError(error, ctx)
+    }
+  }
+
   /**
    * 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) {
-    const modifiedFiles = await this.execGit(['ls-files', '--modified'])
-    if (modifiedFiles) {
-      debug('Detected files modified by tasks:')
-      debug(modifiedFiles)
-      debug('Adding files to index...')
-      await Promise.all(
-        // stagedFileChunks includes staged files that lint-staged originally detected.
-        // Add only these files so any 3rd-party edits to other files won't be included in the commit.
-        this.stagedFileChunks.map(stagedFiles => this.execGit(['add', ...stagedFiles]))
-      )
-      debug('Done adding files to index!')
-    }
-
-    const modifiedFilesAfterAdd = await this.execGit(['status', '--porcelain'])
-    if (!modifiedFilesAfterAdd && !this.allowEmpty) {
+    debug('Adding task modifications to index...')
+    await Promise.all(
+      // stagedFileChunks includes staged files that lint-staged originally detected.
+      // Add only these files so any 3rd-party edits to other files won't be included in the commit.
+      this.stagedFileChunks.map(stagedFiles => this.execGit(['add', ...stagedFiles]))
+    )
+    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
-      ctx.gitApplyEmptyCommit = true
+      ctx.gitApplyEmptyCommitError = true
       handleError(new Error('Prevented an empty git commit!'), ctx)
     }
+  }
 
-    // Restore unstaged changes by applying the diff back. If it at first fails,
-    // this is probably because of conflicts between task modifications.
-    // 3-way merge usually fixes this, and in case it doesn't we should just give up and throw.
-    if (await this.hasPatch(PATCH_UNSTAGED)) {
-      debug('Restoring unstaged changes...')
-      const unstagedPatch = this.getHiddenFilepath(PATCH_UNSTAGED)
-      try {
-        await this.execGit([...GIT_APPLY_ARGS, unstagedPatch])
-      } catch (error) {
-        debug('Error while restoring changes:')
-        debug(error)
-        debug('Retrying with 3-way merge')
-
-        try {
-          // Retry with `--3way` if normal apply fails
-          await this.execGit([...GIT_APPLY_ARGS, '--3way', unstagedPatch])
-        } catch (error2) {
-          debug('Error while restoring unstaged changes using 3-way merge:')
-          debug(error2)
-          ctx.gitApplyModificationsError = true
-          handleError(
-            new Error('Unstaged changes could not be restored due to a merge conflict!'),
-            ctx
-          )
-        }
-      }
-      debug('Done restoring unstaged changes!')
-    }
-
-    // Restore untracked files by reading from the third commit associated with the backup stash
-    // See https://stackoverflow.com/a/52357762
+  /**
+   * 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 {
-      const backupStash = await this.getBackupStash(ctx)
-      const untrackedPatch = this.getHiddenFilepath(PATCH_UNTRACKED)
-      await this.execGit([
-        'show',
-        ...GIT_DIFF_ARGS,
-        '--format=%b',
-        `--output=${untrackedPatch}`,
-        `${backupStash}^3`
-      ])
-      if (await this.hasPatch(PATCH_UNTRACKED)) {
-        await this.execGit([...GIT_APPLY_ARGS, untrackedPatch])
+      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)
+        ctx.gitRestoreUnstagedChangesError = true
+        handleError(
+          new Error('Unstaged changes could not be restored due to a merge conflict!'),
+          ctx
+        )
       }
-    } catch (error) {
-      ctx.gitRestoreUntrackedError = true
-      handleError(error, ctx)
     }
-
-    // If stashing resurrected deleted files, clean them out
-    await Promise.all(this.deletedFiles.map(file => unlink(file)))
   }
 
   /**
@@ -253,16 +286,19 @@ class GitWorkflow {
   async restoreOriginalState(ctx) {
     try {
       debug('Restoring original state...')
-      const backupStash = await this.getBackupStash(ctx)
       await this.execGit(['reset', '--hard', 'HEAD'])
-      await this.execGit(['stash', 'apply', '--quiet', '--index', backupStash])
-      debug('Done restoring original state!')
+      await this.execGit(['stash', 'apply', '--quiet', '--index', await this.getBackupStash(ctx)])
+
+      // Restore meta information about ongoing git merge
+      await this.restoreMergeStatus()
 
       // If stashing resurrected deleted files, clean them out
       await Promise.all(this.deletedFiles.map(file => unlink(file)))
 
-      // Restore meta information about ongoing git merge
-      await this.restoreMergeStatus()
+      // Clean out patch
+      if (this.partiallyStagedFiles) await unlink(PATCH_UNSTAGED)
+
+      debug('Done restoring original state!')
     } catch (error) {
       ctx.gitRestoreOriginalStateError = true
       handleError(error, ctx)
@@ -272,15 +308,10 @@ class GitWorkflow {
   /**
    * Drop the created stashes after everything has run
    */
-  async dropBackup(ctx) {
+  async cleanup(ctx) {
     try {
       debug('Dropping backup stash...')
-      await Promise.all([
-        exists(this.getHiddenFilepath(PATCH_UNSTAGED)).then(unlink),
-        exists(this.getHiddenFilepath(PATCH_UNTRACKED)).then(unlink)
-      ])
-      const backupStash = await this.getBackupStash(ctx)
-      await this.execGit(['stash', 'drop', '--quiet', backupStash])
+      await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)])
       debug('Done dropping backup stash!')
     } catch (error) {
       handleError(error, ctx)
diff --git a/lib/index.js b/lib/index.js
index e5ca47b..4ccffa5 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -46,12 +46,12 @@ function loadConfig(configPath) {
  * @param {boolean | number} [options.concurrent] - The number of tasks to run concurrently, or false to run tasks serially
  * @param {object}  [options.config] - Object with configuration for programmatic API
  * @param {string} [options.configPath] - Path to configuration file
+ * @param {boolean} [options.debug] - Enable debug mode
  * @param {number} [options.maxArgLength] - Maximum argument string length
+ * @param {boolean} [options.quiet] - Disable lint-staged’s own console output
  * @param {boolean} [options.relative] - Pass relative filepaths to tasks
  * @param {boolean} [options.shell] - Skip parsing of tasks for better shell support
- * @param {boolean} [options.quiet] - Disable lint-staged’s own console output
- * @param {boolean} [options.debug] - Enable debug mode
- * @param {boolean | number} [options.concurrent] - The number of tasks to run concurrently, or false to run tasks serially
+ * @param {boolean} [options.stash] - Enable the backup stash, and revert in case of errors
  * @param {Logger} [logger]
  *
  * @returns {Promise<boolean>} Promise of whether the linting passed or failed
@@ -62,11 +62,12 @@ module.exports = async function lintStaged(
     concurrent = true,
     config: configObject,
     configPath,
+    debug = false,
     maxArgLength,
+    quiet = false,
     relative = false,
     shell = false,
-    quiet = false,
-    debug = false
+    stash = true
   } = {},
   logger = console
 ) {
@@ -92,9 +93,13 @@ module.exports = async function lintStaged(
       debugLog('lint-staged config:\n%O', config)
     }
 
+    // Unset GIT_LITERAL_PATHSPECS to not mess with path interpretation
+    debugLog('Unset GIT_LITERAL_PATHSPECS (was `%s`)', process.env.GIT_LITERAL_PATHSPECS)
+    delete process.env.GIT_LITERAL_PATHSPECS
+
     try {
       await runAll(
-        { allowEmpty, concurrent, config, debug, maxArgLength, quiet, relative, shell },
+        { allowEmpty, concurrent, config, debug, maxArgLength, stash, quiet, relative, shell },
         logger
       )
       debugLog('tasks were executed successfully!')
diff --git a/lib/resolveGitRepo.js b/lib/resolveGitRepo.js
index 886e2ab..1ce92db 100644
--- a/lib/resolveGitRepo.js
+++ b/lib/resolveGitRepo.js
@@ -31,9 +31,9 @@ const resolveGitConfigDir = async gitDir => {
 const resolveGitRepo = async cwd => {
   try {
     debugLog('Resolving git repo from `%s`', cwd)
-    // git cli uses GIT_DIR to fast track its response however it might be set to a different path
-    // depending on where the caller initiated this from, hence clear GIT_DIR
-    debugLog('Deleting GIT_DIR from env with value `%s`', process.env.GIT_DIR)
+
+    // Unset GIT_DIR before running any git operations in case it's pointing to an incorrect location
+    debugLog('Unset GIT_DIR (was `%s`)', process.env.GIT_DIR)
     delete process.env.GIT_DIR
 
     const gitDir = normalize(await execGit(['rev-parse', '--show-toplevel'], { cwd }))
diff --git a/lib/runAll.js b/lib/runAll.js
index 17f4fb9..fe5fd1a 100644
--- a/lib/runAll.js
+++ b/lib/runAll.js
@@ -41,14 +41,14 @@ const shouldSkipApplyModifications = ctx => {
 
 const shouldSkipRevert = ctx => {
   // Should be skipped in case of unknown git errors
-  if (ctx.gitError && !ctx.gitApplyEmptyCommit && !ctx.gitApplyModificationsError) {
+  if (ctx.gitError && !ctx.gitApplyEmptyCommitError && !ctx.gitRestoreUnstagedChangesError) {
     return MESSAGES.GIT_ERROR
   }
 }
 
 const shouldSkipCleanup = ctx => {
   // Should be skipped in case of unknown git errors
-  if (ctx.gitError && !ctx.gitApplyEmptyCommit && !ctx.gitApplyModificationsError) {
+  if (ctx.gitError && !ctx.gitApplyEmptyCommitError && !ctx.gitRestoreUnstagedChangesError) {
     return MESSAGES.GIT_ERROR
   }
   // Should be skipped when reverting to original state fails
@@ -62,20 +62,22 @@ const shouldSkipCleanup = ctx => {
  *
  * @param {object} options
  * @param {Object} [options.allowEmpty] - Allow empty commits when tasks revert all staged changes
+ * @param {boolean | number} [options.concurrent] - The number of tasks to run concurrently, or false to run tasks serially
  * @param {Object} [options.config] - Task configuration
  * @param {Object} [options.cwd] - Current working directory
+ * @param {boolean} [options.debug] - Enable debug mode
  * @param {number} [options.maxArgLength] - Maximum argument string length
+ * @param {boolean} [options.quiet] - Disable lint-staged’s own console output
  * @param {boolean} [options.relative] - Pass relative filepaths to tasks
  * @param {boolean} [options.shell] - Skip parsing of tasks for better shell support
- * @param {boolean} [options.quiet] - Disable lint-staged’s own console output
- * @param {boolean} [options.debug] - Enable debug mode
- * @param {boolean | number} [options.concurrent] - The number of tasks to run concurrently, or false to run tasks serially
+ * @param {boolean} [options.stash] - Enable the backup stash, and revert in case of errors
  * @param {Logger} logger
  * @returns {Promise}
  */
 const runAll = async (
   {
     allowEmpty = false,
+    concurrent = true,
     config,
     cwd = process.cwd(),
     debug = false,
@@ -83,12 +85,18 @@ const runAll = async (
     quiet = false,
     relative = false,
     shell = false,
-    concurrent = true
+    stash = true
   },
   logger = console
 ) => {
   debugLog('Running all linter scripts')
 
+  if (!stash) {
+    logger.warn(
+      `${symbols.warning} ${chalk.yellow('Skipping backup because `--no-stash` was used.')}`
+    )
+  }
+
   const { gitDir, gitConfigDir } = await resolveGitRepo(cwd)
   if (!gitDir) throw new Error('Current directory is not a git directory!')
 
@@ -98,8 +106,7 @@ const runAll = async (
 
   // If there are no files avoid executing any lint-staged logic
   if (files.length === 0) {
-    logger.log('No staged files found.')
-    return 'No tasks to run.'
+    return logger.log(`${symbols.info} No staged files found.`)
   }
 
   const stagedFileChunks = chunkFiles({ files, gitDir, maxArgLength, relative })
@@ -189,23 +196,38 @@ const runAll = async (
     [
       {
         title: 'Preparing...',
-        task: ctx => git.stashBackup(ctx)
+        task: ctx => git.prepare(ctx, stash)
+      },
+      {
+        title: 'Hiding unstaged changes to partially staged files...',
+        task: ctx => git.hideUnstagedChanges(ctx),
+        enabled: ctx => ctx.hasPartiallyStagedFiles
       },
       ...listrTasks,
       {
         title: 'Applying modifications...',
         task: ctx => git.applyModifications(ctx),
+        // Always apply back unstaged modifications when skipping backup
+        skip: ctx => stash && shouldSkipApplyModifications(ctx)
+      },
+      {
+        title: 'Restoring unstaged changes to partially staged files...',
+        task: ctx => git.restoreUnstagedChanges(ctx),
+        enabled: ctx => ctx.hasPartiallyStagedFiles,
         skip: shouldSkipApplyModifications
       },
       {
-        title: 'Reverting to original state...',
+        title: 'Reverting to original state because of errors...',
         task: ctx => git.restoreOriginalState(ctx),
-        enabled: ctx => ctx.taskError || ctx.gitApplyEmptyCommit || ctx.gitApplyModificationsError,
+        enabled: ctx =>
+          stash &&
+          (ctx.taskError || ctx.gitApplyEmptyCommitError || ctx.gitRestoreUnstagedChangesError),
         skip: shouldSkipRevert
       },
       {
         title: 'Cleaning up...',
-        task: ctx => git.dropBackup(ctx),
+        task: ctx => git.cleanup(ctx),
+        enabled: () => stash,
         skip: shouldSkipCleanup
       }
     ],
@@ -215,7 +237,7 @@ const runAll = async (
   try {
     await runner.run({})
   } catch (error) {
-    if (error.context.gitApplyEmptyCommit) {
+    if (error.context.gitApplyEmptyCommitError) {
       logger.warn(`
   ${symbols.warning} ${chalk.yellow(`lint-staged prevented an empty git commit.
     Use the --allow-empty option to continue, or check your task configuration`)}
@@ -228,7 +250,7 @@ const runAll = async (
           `\n    The initial commit is needed for lint-staged to work.
     Please use the --no-verify flag to skip running lint-staged.`
         )
-      } else {
+      } else if (stash) {
         // No sense to show this if the backup stash itself is missing.
         logger.error(`  Any lost modifications can be restored from a git stash:
 


Test patch:
diff --git a/test/__mocks__/gitWorkflow.js b/test/__mocks__/gitWorkflow.js
index 2126551..77b146f 100644
--- a/test/__mocks__/gitWorkflow.js
+++ b/test/__mocks__/gitWorkflow.js
@@ -1,8 +1,10 @@
 const stub = {
-  stashBackup: jest.fn().mockImplementation(() => Promise.resolve()),
+  prepare: jest.fn().mockImplementation(() => Promise.resolve()),
+  hideUnstagedChanges: jest.fn().mockImplementation(() => Promise.resolve()),
   applyModifications: jest.fn().mockImplementation(() => Promise.resolve()),
+  restoreUnstagedChanges: jest.fn().mockImplementation(() => Promise.resolve()),
   restoreOriginalState: jest.fn().mockImplementation(() => Promise.resolve()),
-  dropBackup: jest.fn().mockImplementation(() => Promise.resolve())
+  cleanup: jest.fn().mockImplementation(() => Promise.resolve())
 }
 
 module.exports = () => stub
diff --git a/test/__snapshots__/runAll.spec.js.snap b/test/__snapshots__/runAll.spec.js.snap
index 6eb4331..c0f1bb4 100644
--- a/test/__snapshots__/runAll.spec.js.snap
+++ b/test/__snapshots__/runAll.spec.js.snap
@@ -18,7 +18,7 @@ LOG Cleaning up... [completed]"
 
 exports[`runAll should resolve the promise with no files 1`] = `
 "
-LOG No staged files found."
+LOG i No staged files found."
 `;
 
 exports[`runAll should skip applying unstaged modifications if there are errors during linting 1`] = `
@@ -36,8 +36,8 @@ LOG Running tasks... [failed]
 LOG Applying modifications... [started]
 LOG Applying modifications... [skipped]
 LOG → Skipped because of errors from tasks.
-LOG Reverting to original state... [started]
-LOG Reverting to original state... [completed]
+LOG Reverting to original state because of errors... [started]
+LOG Reverting to original state because of errors... [completed]
 LOG Cleaning up... [started]
 LOG Cleaning up... [completed]
 LOG {
@@ -67,8 +67,8 @@ LOG Running tasks... [failed]
 LOG Applying modifications... [started]
 LOG Applying modifications... [skipped]
 LOG → Skipped because of errors from tasks.
-LOG Reverting to original state... [started]
-LOG Reverting to original state... [completed]
+LOG Reverting to original state because of errors... [started]
+LOG Reverting to original state because of errors... [completed]
 LOG Cleaning up... [started]
 LOG Cleaning up... [completed]
 LOG {
@@ -85,5 +85,5 @@ LOG {
 
 exports[`runAll should use an injected logger 1`] = `
 "
-LOG No staged files found."
+LOG i No staged files found."
 `;
diff --git a/test/gitWorkflow.spec.js b/test/gitWorkflow.spec.js
index 97d7cb5..6837e24 100644
--- a/test/gitWorkflow.spec.js
+++ b/test/gitWorkflow.spec.js
@@ -65,24 +65,14 @@ describe('gitWorkflow', () => {
     }
   })
 
-  describe('hasPatch', () => {
-    it('should return false when patch file not found', async () => {
-      const gitWorkflow = new GitWorkflow({
-        gitDir: cwd,
-        gitConfigDir: path.resolve(cwd, './.git')
-      })
-      expect(await gitWorkflow.hasPatch('foo')).toEqual(false)
-    })
-  })
-
-  describe('dropBackup', () => {
+  describe('cleanup', () => {
     it('should handle errors', async () => {
       const gitWorkflow = new GitWorkflow({
         gitDir: cwd,
         gitConfigDir: path.resolve(cwd, './.git')
       })
       const ctx = {}
-      await expect(gitWorkflow.dropBackup(ctx)).rejects.toThrowErrorMatchingInlineSnapshot(
+      await expect(gitWorkflow.cleanup(ctx)).rejects.toThrowErrorMatchingInlineSnapshot(
         `"lint-staged automatic backup is missing!"`
       )
       expect(ctx).toEqual({
@@ -92,18 +82,22 @@ describe('gitWorkflow', () => {
     })
   })
 
-  describe('cleanUntrackedFiles', () => {
-    it('should remove untracked files', async () => {
-      const tempFile = path.resolve(cwd, 'tempFile')
-      await fs.writeFile(tempFile, 'Hello')
-
+  describe('hideUnstagedChanges', () => {
+    it('should handle errors', async () => {
       const gitWorkflow = new GitWorkflow({
         gitDir: cwd,
         gitConfigDir: path.resolve(cwd, './.git')
       })
-
-      await gitWorkflow.cleanUntrackedFiles()
-      await expect(fs.access(tempFile)).rejects.toThrow('ENOENT')
+      const totallyRandom = `totally_random_file-${Date.now().toString()}`
+      gitWorkflow.partiallyStagedFiles = [totallyRandom]
+      const ctx = {}
+      await expect(gitWorkflow.hideUnstagedChanges(ctx)).rejects.toThrowErrorMatchingInlineSnapshot(
+        `"error: pathspec '${totallyRandom}' did not match any file(s) known to git"`
+      )
+      expect(ctx).toEqual({
+        gitError: true,
+        gitHideUnstagedChangesError: true
+      })
     })
   })
 })
diff --git a/test/runAll.spec.js b/test/runAll.spec.js
index f19b756..ffdf038 100644
--- a/test/runAll.spec.js
+++ b/test/runAll.spec.js
@@ -33,8 +33,7 @@ describe('runAll', () => {
   })
 
   it('should resolve the promise with no tasks', async () => {
-    const res = await runAll({ config: {} })
-    expect(res).toEqual('No tasks to run.')
+    await expect(runAll({ config: {} })).resolves
   })
 
   it('should resolve the promise with no files', async () => {
diff --git a/test/runAll.unmocked.spec.js b/test/runAll.unmocked.spec.js
index 88cfd68..f4e94fd 100644
--- a/test/runAll.unmocked.spec.js
+++ b/test/runAll.unmocked.spec.js
@@ -115,8 +115,7 @@ describe('runAll', () => {
   })
 
   it('should exit early with no staged files', async () => {
-    const status = await runAll({ config: { '*.js': 'echo success' }, cwd })
-    expect(status).toEqual('No tasks to run.')
+    expect(() => runAll({ config: { '*.js': 'echo success' }, cwd })).resolves
   })
 
   it('Should commit entire staged file when no errors from linter', async () => {
@@ -234,7 +233,26 @@ describe('runAll', () => {
     await appendFile('test.js', appended)
 
     // Run lint-staged with `prettier --list-different` and commit pretty file
-    await gitCommit({ config: { '*.js': 'prettier --list-different' } })
+    await gitCommit({ config: { '*.js': 'prettier --list-different' }, quiet: false })
+    expect(console.printHistory()).toMatchInlineSnapshot(`
+      "
+      LOG Preparing... [started]
+      LOG Preparing... [completed]
+      LOG Hiding unstaged changes to partially staged files... [started]
+      LOG Hiding unstaged changes to partially staged files... [completed]
+      LOG Running tasks... [started]
+      LOG Running tasks for *.js [started]
+      LOG prettier --list-different [started]
+      LOG prettier --list-different [completed]
+      LOG Running tasks for *.js [completed]
+      LOG Running tasks... [completed]
+      LOG Applying modifications... [started]
+      LOG Applying modifications... [completed]
+      LOG Restoring unstaged changes to partially staged files... [started]
+      LOG Restoring unstaged changes to partially staged files... [completed]
+      LOG Cleaning up... [started]
+      LOG Cleaning up... [completed]"
+    `)
 
     // Nothing is wrong, so a new commit is created and file is pretty
     expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2')
@@ -291,11 +309,34 @@ describe('runAll', () => {
     const status = await execGit(['status'])
 
     // Run lint-staged with `prettier --list-different` to break the linter
-    try {
-      await gitCommit({ config: { '*.js': 'prettier --list-different' } })
-    } catch (error) {
-      expect(error.message).toMatchInlineSnapshot(`"Something went wrong"`)
-    }
+    await expect(
+      gitCommit({ config: { '*.js': 'prettier --list-different' }, quiet: false })
+    ).rejects.toThrowError()
+    expect(console.printHistory()).toMatchInlineSnapshot(`
+      "
+      LOG Preparing... [started]
+      LOG Preparing... [completed]
+      LOG Hiding unstaged changes to partially staged files... [started]
+      LOG Hiding unstaged changes to partially staged files... [completed]
+      LOG Running tasks... [started]
+      LOG Running tasks for *.js [started]
+      LOG prettier --list-different [started]
+      LOG prettier --list-different [failed]
+      LOG → 
+      LOG Running tasks for *.js [failed]
+      LOG → 
+      LOG Running tasks... [failed]
+      LOG Applying modifications... [started]
+      LOG Applying modifications... [skipped]
+      LOG → Skipped because of errors from tasks.
+      LOG Restoring unstaged changes to partially staged files... [started]
+      LOG Restoring unstaged changes to partially staged files... [skipped]
+      LOG → Skipped because of errors from tasks.
+      LOG Reverting to original state because of errors... [started]
+      LOG Reverting to original state because of errors... [completed]
+      LOG Cleaning up... [started]
+      LOG Cleaning up... [completed]"
+    `)
 
     // Something was wrong so the repo is returned to original state
     expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1')
@@ -519,11 +560,7 @@ describe('runAll', () => {
     expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('commit a')
 
     // Merge second branch, causing merge conflict
-    try {
-      await execGit(['merge', 'branch-b'])
-    } catch (error) {
-      expect(error.message).toMatch('Merge conflict in test.js')
-    }
+    await expect(execGit(['merge', 'branch-b'])).rejects.toThrowError('Merge conflict in test.js')
 
     expect(await readFile('test.js')).toMatchInlineSnapshot(`
       "<<<<<<< HEAD
@@ -715,11 +752,10 @@ describe('runAll', () => {
       LOG Running tasks for *.js [completed]
       LOG Running tasks... [completed]
       LOG Applying modifications... [started]
-      LOG Applying modifications... [failed]
-      LOG → lint-staged automatic backup is missing!
+      LOG Applying modifications... [completed]
       LOG Cleaning up... [started]
-      LOG Cleaning up... [skipped]
-      LOG → Skipped because of previous git error."
+      LOG Cleaning up... [failed]
+      LOG → lint-staged automatic backup is missing!"
     `)
   })
 
@@ -870,6 +906,138 @@ describe('runAll', () => {
       expect(await readFile('👋.js')).toEqual(testJsFilePretty)
     }
   )
+
+  it('should skip backup and revert with --no-backup', async () => {
+    // Stage pretty file
+    await appendFile('test.js', testJsFileUgly)
+    await execGit(['add', 'test.js'])
+
+    // Run lint-staged with --no-stash
+    await gitCommit({
+      ...fixJsConfig,
+      stash: false,
+      quiet: false
+    })
+
+    expect(console.printHistory()).toMatchInlineSnapshot(`
+      "
+      WARN ‼ Skipping backup because \`--no-stash\` was used.
+      LOG Preparing... [started]
+      LOG Preparing... [completed]
+      LOG Running tasks... [started]
+      LOG Running tasks for *.js [started]
+      LOG prettier --write [started]
+      LOG prettier --write [completed]
+      LOG Running tasks for *.js [completed]
+      LOG Running tasks... [completed]
+      LOG Applying modifications... [started]
+      LOG Applying modifications... [completed]"
+    `)
+
+    // Nothing is wrong, so a new commit is created
+    expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('2')
+    expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('test')
+    expect(await readFile('test.js')).toEqual(testJsFilePretty)
+  })
+
+  it('should abort commit without reverting with --no-stash 1', async () => {
+    // Stage file
+    await appendFile('test.js', testJsFileUgly)
+    await execGit(['add', 'test.js'])
+
+    // Run lint-staged with action that does horrible things to the file, causing a merge conflict
+    const testFile = path.resolve(cwd, 'test.js')
+    await expect(
+      gitCommit({
+        config: {
+          '*.js': () => {
+            fs.writeFileSync(testFile, Buffer.from(testJsFileUnfixable, 'binary'))
+            return `prettier --write ${testFile}`
+          }
+        },
+        quiet: false,
+        stash: false
+      })
+    ).rejects.toThrowError()
+
+    expect(console.printHistory()).toMatchInlineSnapshot(`
+      "
+      WARN ‼ Skipping backup because \`--no-stash\` was used.
+      LOG Preparing... [started]
+      LOG Preparing... [completed]
+      LOG Hiding unstaged changes to partially staged files... [started]
+      LOG Hiding unstaged changes to partially staged files... [completed]
+      LOG Running tasks... [started]
+      LOG Running tasks for *.js [started]
+      LOG [Function] prettier ... [started]
+      LOG [Function] prettier ... [completed]
+      LOG Running tasks for *.js [completed]
+      LOG Running tasks... [completed]
+      LOG Applying modifications... [started]
+      LOG Applying modifications... [completed]
+      LOG Restoring unstaged changes to partially staged files... [started]
+      LOG Restoring unstaged changes to partially staged files... [failed]
+      LOG → Unstaged changes could not be restored due to a merge conflict!
+      ERROR 
+        × lint-staged failed due to a git error."
+    `)
+
+    // Something was wrong so the commit was aborted
+    expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1')
+    expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit')
+    expect(await execGit(['status', '--porcelain'])).toMatchInlineSnapshot(`"UU test.js"`)
+    // Without revert, the merge conflict is left in-place
+    expect(await readFile('test.js')).toMatchInlineSnapshot(`
+      "<<<<<<< ours
+      module.exports = {
+        foo: \\"bar\\"
+      };
+      =======
+      const obj = {
+          'foo': 'bar'
+      >>>>>>> theirs
+      "
+    `)
+  })
+
+  it('should abort commit without reverting with --no-stash 2', async () => {
+    await appendFile('test.js', testJsFileUgly)
+    await execGit(['add', 'test.js'])
+    await appendFile('test2.js', testJsFileUnfixable)
+    await execGit(['add', 'test2.js'])
+
+    // Run lint-staged with --no-stash
+    await expect(
+      gitCommit({
+        ...fixJsConfig,
+        quiet: false,
+        stash: false
+      })
+    ).rejects.toThrowErrorMatchingInlineSnapshot(`"Something went wrong"`)
+
+    expect(console.printHistory()).toMatchInlineSnapshot(`
+      "
+      WARN ‼ Skipping backup because \`--no-stash\` was used.
+      LOG Preparing... [started]
+      LOG Preparing... [completed]
+      LOG Running tasks... [started]
+      LOG Running tasks for *.js [started]
+      LOG prettier --write [started]
+      LOG prettier --write [failed]
+      LOG → 
+      LOG Running tasks for *.js [failed]
+      LOG → 
+      LOG Running tasks... [failed]
+      LOG Applying modifications... [started]
+      LOG Applying modifications... [completed]"
+    `)
+
+    // Something was wrong, so the commit was aborted
+    expect(await execGit(['rev-list', '--count', 'HEAD'])).toEqual('1')
+    expect(await execGit(['log', '-1', '--pretty=%B'])).toMatch('initial commit')
+    expect(await readFile('test.js')).toEqual(testJsFilePretty) // file was still fixed
+    expect(await readFile('test2.js')).toEqual(testJsFileUnfixable)
+  })
 })
 
 describe('runAll', () => {