Repository: pre-commit/pre-commit-hooks

Issue:
Filenames with unusual characters break check_executables_have_shebangs
The `check_executables_have_shebangs` gets file paths with the output of the following git command:

```
git ls-files --stage -- path1 path2
```

I'm not sure about Linux, but on Windows, when the filename contains an unusual character (sorry about the choice of "unusual" to describe the character, I don't want to speculate on encoding issues), the character is escaped with integer sequences, and git wraps the file path in double-quotes (because of the backslashes I guess):

```console
$ git ls-files --stage -- tests/demo/doc/mañana.txt tests/demo/doc/manana.txt
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0       tests/demo/doc/manana.txt
100644 5ab9dcdd36df0f76f202227ecb7ae8a5baaa456b 0       "tests/demo/doc/ma\303\261ana.txt"
```

The resulting path variable becomes `"tests/demo/doc/ma\303\261ana.txt"`, and then the script tries to `open` it, which fails of course because of the quotes and escaping:

```
Traceback (most recent call last):
  File "C:\Users\user\AppData\Local\Programs\Python\Python36\lib\runpy.py", line 193, in _run_module_as_main
    "__main__", mod_spec)
  File "C:\Users\user\AppData\Local\Programs\Python\Python36\lib\runpy.py", line 85, in _run_code
    exec(code, run_globals)
  File "C:\Users\user\.cache\pre-commit\repofzzr3u3t\py_env-python3\Scripts\check-executables-have-shebangs.EXE\__main__.py", line 7, in <module>
  File "c:\users\user\.cache\pre-commit\repofzzr3u3t\py_env-python3\lib\site-packages\pre_commit_hooks\check_executables_have_shebangs.py", line 66, in main
    return check_executables(args.filenames)
  File "c:\users\user\.cache\pre-commit\repofzzr3u3t\py_env-python3\lib\site-packages\pre_commit_hooks\check_executables_have_shebangs.py", line 17, in check_executables
    return _check_git_filemode(paths)
  File "c:\users\user\.cache\pre-commit\repofzzr3u3t\py_env-python3\lib\site-packages\pre_commit_hooks\check_executables_have_shebangs.py", line 36, in _check_git_filemode
    has_shebang = _check_has_shebang(path)
  File "c:\users\user\.cache\pre-commit\repofzzr3u3t\py_env-python3\lib\site-packages\pre_commit_hooks\check_executables_have_shebangs.py", line 45, in _check_has_shebang
    with open(path, 'rb') as f:
OSError: [Errno 22] Invalid argument: '"tests/demo/doc/ma\\303\\261ana.txt"'
```

To fix the quotes issue, the pre-commit script could try to remove them with a `.strip('"')` call.

For the character escaping, I have no idea! Do you 😄 ?

Ref: https://github.com/copier-org/copier/pull/224#issuecomment-665079662

Discussion:
pawamoy: I used a ternary operator in the `zsplit` method to avoid coverage falling under 100. I honestly don't feel like testing `zsplit` to make sure the `else return []` branch is met with an input like `\0\0` is useful, but if you still want it, I can add it 🙂 
asottile: > 
> 
> I used a ternary operator in the `zsplit` method to avoid coverage falling under 100. Unless we move `zsplit` in the utils module, I don't think testing it to make sure the `else return []` branch is met with an input like `\0\0` is useful, but if you still want it, I can add it 🙂

testing this is important, it's the entire reason it's a separate function and not just `.split('\0')`
pawamoy: Fair enough, I'll write some tests 🙂 
pawamoy: Test correctly failing without the fix:

```
_____________ test_check_git_filemode_passing_unusual_characters ______________

tmpdir = local('C:\\Users\\MAZZUCTI\\AppData\\Local\\Temp\\1\\pytest-of-unknown\\pytest-6\\test_check_git_filemode_passin1')

    def test_check_git_filemode_passing_unusual_characters(tmpdir):
        with tmpdir.as_cwd():
            cmd_output('git', 'init', '.')

            f = tmpdir.join('ma▒ana.txt')
            f.write('#!/usr/bin/env bash')
            f_path = str(f)
            cmd_output('chmod', '+x', f_path)
            cmd_output('git', 'add', f_path)
            cmd_output('git', 'update-index', '--chmod=+x', f_path)

            files = (f_path,)
>           assert check_executables_have_shebangs._check_git_filemode(files) == 0

tests\check_executables_have_shebangs_test.py:88:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
pre_commit_hooks\check_executables_have_shebangs.py:44: in _check_git_filemode
    has_shebang = _check_has_shebang(path)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

path = '"ma\\303\\261ana.txt"'

    def _check_has_shebang(path: str) -> int:
>       with open(path, 'rb') as f:
E       OSError: [Errno 22] Invalid argument: '"ma\\303\\261ana.txt"'

pre_commit_hooks\check_executables_have_shebangs.py:53: OSError
```

Patch:
diff --git a/pre_commit_hooks/check_executables_have_shebangs.py b/pre_commit_hooks/check_executables_have_shebangs.py
index 1c50ea0..a02d2a9 100644
--- a/pre_commit_hooks/check_executables_have_shebangs.py
+++ b/pre_commit_hooks/check_executables_have_shebangs.py
@@ -12,6 +12,14 @@ from pre_commit_hooks.util import cmd_output
 EXECUTABLE_VALUES = frozenset(('1', '3', '5', '7'))
 
 
+def zsplit(s: str) -> List[str]:
+    s = s.strip('\0')
+    if s:
+        return s.split('\0')
+    else:
+        return []
+
+
 def check_executables(paths: List[str]) -> int:
     if sys.platform == 'win32':  # pragma: win32 cover
         return _check_git_filemode(paths)
@@ -26,9 +34,9 @@ def check_executables(paths: List[str]) -> int:
 
 
 def _check_git_filemode(paths: Sequence[str]) -> int:
-    outs = cmd_output('git', 'ls-files', '--stage', '--', *paths)
+    outs = cmd_output('git', 'ls-files', '-z', '--stage', '--', *paths)
     seen: Set[str] = set()
-    for out in outs.splitlines():
+    for out in zsplit(outs):
         metadata, path = out.split('\t')
         tagmode = metadata.split(' ', 1)[0]
 


Test patch:
diff --git a/tests/check_executables_have_shebangs_test.py b/tests/check_executables_have_shebangs_test.py
index 5895a2a..7046081 100644
--- a/tests/check_executables_have_shebangs_test.py
+++ b/tests/check_executables_have_shebangs_test.py
@@ -73,6 +73,21 @@ def test_check_git_filemode_passing(tmpdir):
         assert check_executables_have_shebangs._check_git_filemode(files) == 0
 
 
+def test_check_git_filemode_passing_unusual_characters(tmpdir):
+    with tmpdir.as_cwd():
+        cmd_output('git', 'init', '.')
+
+        f = tmpdir.join('mañana.txt')
+        f.write('#!/usr/bin/env bash')
+        f_path = str(f)
+        cmd_output('chmod', '+x', f_path)
+        cmd_output('git', 'add', f_path)
+        cmd_output('git', 'update-index', '--chmod=+x', f_path)
+
+        files = (f_path,)
+        assert check_executables_have_shebangs._check_git_filemode(files) == 0
+
+
 def test_check_git_filemode_failing(tmpdir):
     with tmpdir.as_cwd():
         cmd_output('git', 'init', '.')
@@ -87,6 +102,16 @@ def test_check_git_filemode_failing(tmpdir):
         assert check_executables_have_shebangs._check_git_filemode(files) == 1
 
 
+@pytest.mark.parametrize('out', ('\0f1\0f2\0', '\0f1\0f2', 'f1\0f2\0'))
+def test_check_zsplits_correctly(out):
+    assert check_executables_have_shebangs.zsplit(out) == ['f1', 'f2']
+
+
+@pytest.mark.parametrize('out', ('\0\0', '\0', ''))
+def test_check_zsplit_returns_empty(out):
+    assert check_executables_have_shebangs.zsplit(out) == []
+
+
 @pytest.mark.parametrize(
     ('content', 'mode', 'expected'),
     (