Repository: okonet/lint-staged
Pull request #876 (2020-05-29 00:12:43)

Use machine output for listing partially staged files
When a partially staged file name contains one or more spaces or special characters, `git status --porcelain` escapes and quotes the file name (see https://github.com/okonet/lint-staged/blob/master/lib/gitWorkflow.js#L161). Consequently, `gitWorkflow.hideUnstagedChanges()` fails with a pathspec error since `execa` expects arguments to not be escaped or quoted:

```
[SUCCESS] Preparing...
[STARTED] Hiding unstaged changes to partially staged files...
[FAILED] error: pathspec '"src/file with spaces.ts"' did not match any file(s) known to git

  ✖ lint-staged failed due to a git error.
  Any lost modifications can be restored from a git stash:

    > git stash list
    stash@{0}: automatic lint-staged backup
    > git stash apply --index stash@{0}
```

This PR resolves the problem by using the machine output from `git-status` instead. Note: the order of renamed files in the status output is reversed and separated by a `NUL` character (instead of ` -> `) when machine output (`-z` option) is specified.


Patch:
diff --git a/lib/gitWorkflow.js b/lib/gitWorkflow.js
index bf24e14..a5838ab 100644
--- a/lib/gitWorkflow.js
+++ b/lib/gitWorkflow.js
@@ -19,19 +19,20 @@ const MERGE_HEAD = 'MERGE_HEAD'
 const MERGE_MODE = 'MERGE_MODE'
 const MERGE_MSG = 'MERGE_MSG'
 
-// In git status, renames are presented as `from` -> `to`.
+// 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`.
-const RENAME = / -> /
+// eslint-disable-next-line no-control-regex
+const RENAME = /\x00/
 
 /**
- * From list of files, split renames and flatten into two files `from` -> `to`.
+ * 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 [from, to] = file.split(RENAME)
+      const [to, from] = file.split(RENAME)
       if (includeRenameFrom) flattened.push(from)
       flattened.push(to)
     } else {
@@ -158,15 +159,20 @@ class GitWorkflow {
    */
   async getPartiallyStagedFiles() {
     debug('Getting partially staged files...')
-    const status = await this.execGit(['status', '--porcelain'])
+    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
-      .split('\n')
+      // eslint-disable-next-line no-control-regex
+      .split(/\x00(?=[ AMDRCU?!]{2} |$)/)
       .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 !== '?'
       })


Test patch:
diff --git a/test/gitWorkflow.spec.js b/test/gitWorkflow.spec.js
index 01ab41e..8752921 100644
--- a/test/gitWorkflow.spec.js
+++ b/test/gitWorkflow.spec.js
@@ -19,6 +19,8 @@ let tmpDir, cwd
 const appendFile = async (filename, content, dir = cwd) =>
   fs.appendFile(path.resolve(dir, filename), content)
 
+const readFile = async (filename, dir = cwd) => fs.readFile(path.resolve(dir, filename))
+
 /** Wrap execGit to always pass `gitOps` */
 const execGit = async (args) => execGitBase(args, { cwd })
 
@@ -102,6 +104,41 @@ describe('gitWorkflow', () => {
     })
   })
 
+  describe('getPartiallyStagedFiles', () => {
+    it('should return unquoted files', async () => {
+      const gitWorkflow = new GitWorkflow({
+        gitDir: cwd,
+        gitConfigDir: path.resolve(cwd, './.git'),
+      })
+      await appendFile('file with spaces.txt', 'staged content')
+      await appendFile('file_without_spaces.txt', 'staged content')
+      await execGit(['add', 'file with spaces.txt'])
+      await execGit(['add', 'file_without_spaces.txt'])
+      await appendFile('file with spaces.txt', 'not staged content')
+      await appendFile('file_without_spaces.txt', 'not staged content')
+
+      expect(await gitWorkflow.getPartiallyStagedFiles()).toStrictEqual([
+        'file with spaces.txt',
+        'file_without_spaces.txt',
+      ])
+    })
+    it('should include to and from for renamed files', async () => {
+      const gitWorkflow = new GitWorkflow({
+        gitDir: cwd,
+        gitConfigDir: path.resolve(cwd, './.git'),
+      })
+      await appendFile('original.txt', 'test content')
+      await execGit(['add', 'original.txt'])
+      await execGit(['commit', '-m "Add original.txt"'])
+      await appendFile('original.txt', 'additional content')
+      await execGit(['mv', 'original.txt', 'renamed.txt'])
+
+      expect(await gitWorkflow.getPartiallyStagedFiles()).toStrictEqual([
+        'renamed.txt\u0000original.txt',
+      ])
+    })
+  })
+
   describe('hideUnstagedChanges', () => {
     it('should handle errors', async () => {
       const gitWorkflow = new GitWorkflow({
@@ -127,6 +164,20 @@ describe('gitWorkflow', () => {
         }
       `)
     })
+    it('should checkout renamed file when hiding changes', async () => {
+      const gitWorkflow = new GitWorkflow({
+        gitDir: cwd,
+        gitConfigDir: path.resolve(cwd, './.git'),
+      })
+      const origContent = await readFile('README.md')
+      await execGit(['mv', 'README.md', 'TEST.md'])
+      await appendFile('TEST.md', 'added content')
+
+      gitWorkflow.partiallyStagedFiles = await gitWorkflow.getPartiallyStagedFiles()
+      const ctx = getInitialState()
+      await gitWorkflow.hideUnstagedChanges(ctx)
+      expect(await readFile('TEST.md')).toStrictEqual(origContent)
+    })
   })
 
   describe('restoreMergeStatus', () => {