Repository: pre-commit/pre-commit

Issue:
Pre-commit install fails if existing hook is not utf-8
It looks like pre-commit tries to read the contents of an already installed hook blindly, without first checking if its a text file and not binary blob.

### version information

```
pre-commit version: 2.13.0
sys.version:
    3.8.5 (default, May 27 2021, 13:30:53) 
    [GCC 9.3.0]
sys.executable: /usr/bin/python3
os.name: posix
sys.platform: linux
```

### error information

```
An unexpected error has occurred: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc8 in position 40: invalid continuation byte
```

```
Traceback (most recent call last):
  File "/home/user/.local/lib/python3.8/site-packages/pre_commit/error_handler.py", line 65, in error_handler
    yield
  File "/home/user/.local/lib/python3.8/site-packages/pre_commit/main.py", line 367, in main
    return install(
  File "/home/user/.local/lib/python3.8/site-packages/pre_commit/commands/install_uninstall.py", line 139, in install
    _install_hook_script(
  File "/home/user/.local/lib/python3.8/site-packages/pre_commit/commands/install_uninstall.py", line 88, in _install_hook_script
    if os.path.lexists(hook_path) and not is_our_script(hook_path):
  File "/home/user/.local/lib/python3.8/site-packages/pre_commit/commands/install_uninstall.py", line 52, in is_our_script
    contents = f.read()
  File "/usr/lib/python3.8/codecs.py", line 322, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc8 in position 40: invalid continuation byte
```

Discussion:

Patch:
diff --git a/pre_commit/commands/install_uninstall.py b/pre_commit/commands/install_uninstall.py
index 684b598..73c8d60 100644
--- a/pre_commit/commands/install_uninstall.py
+++ b/pre_commit/commands/install_uninstall.py
@@ -21,13 +21,13 @@ logger = logging.getLogger(__name__)
 
 # This is used to identify the hook file we install
 PRIOR_HASHES = (
-    '4d9958c90bc262f47553e2c073f14cfe',
-    'd8ee923c46731b42cd95cc869add4062',
-    '49fd668cb42069aa1b6048464be5d395',
-    '79f09a650522a87b0da915d0d983b2de',
-    'e358c9dae00eac5d06b38dfdb1e33a8c',
+    b'4d9958c90bc262f47553e2c073f14cfe',
+    b'd8ee923c46731b42cd95cc869add4062',
+    b'49fd668cb42069aa1b6048464be5d395',
+    b'79f09a650522a87b0da915d0d983b2de',
+    b'e358c9dae00eac5d06b38dfdb1e33a8c',
 )
-CURRENT_HASH = '138fd403232d2ddd5efb44317e38bf03'
+CURRENT_HASH = b'138fd403232d2ddd5efb44317e38bf03'
 TEMPLATE_START = '# start templated\n'
 TEMPLATE_END = '# end templated\n'
 # Homebrew/homebrew-core#35825: be more timid about appropriate `PATH`
@@ -48,7 +48,7 @@ def _hook_paths(
 def is_our_script(filename: str) -> bool:
     if not os.path.exists(filename):  # pragma: win32 no cover (symlink)
         return False
-    with open(filename) as f:
+    with open(filename, 'rb') as f:
         contents = f.read()
     return any(h in contents for h in (CURRENT_HASH,) + PRIOR_HASHES)
 


Test patch:
diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py
index bd28654..314b8b9 100644
--- a/tests/commands/install_uninstall_test.py
+++ b/tests/commands/install_uninstall_test.py
@@ -39,7 +39,7 @@ def test_is_script():
 
 def test_is_previous_pre_commit(tmpdir):
     f = tmpdir.join('foo')
-    f.write(f'{PRIOR_HASHES[0]}\n')
+    f.write(f'{PRIOR_HASHES[0].decode()}\n')
     assert is_our_script(f.strpath)
 
 
@@ -390,6 +390,19 @@ def test_install_existing_hook_no_overwrite_idempotent(tempdir_factory, store):
         NORMAL_PRE_COMMIT_RUN.assert_matches(output[len('legacy hook\n'):])
 
 
+def test_install_with_existing_non_utf8_script(tmpdir, store):
+    cmd_output('git', 'init', str(tmpdir))
+    tmpdir.join('.git/hooks').ensure_dir()
+    tmpdir.join('.git/hooks/pre-commit').write_binary(
+        b'#!/usr/bin/env bash\n'
+        b'# garbage: \xa0\xef\x12\xf2\n'
+        b'echo legacy hook\n',
+    )
+
+    with tmpdir.as_cwd():
+        assert install(C.CONFIG_FILE, store, hook_types=['pre-commit']) == 0
+
+
 FAIL_OLD_HOOK = re_assert.Matches(
     r'fail!\n'
     r'\[INFO\] Initializing environment for .+\.\n'
@@ -460,7 +473,7 @@ def test_replace_old_commit_script(tempdir_factory, store):
         # Install a script that looks like our old script
         pre_commit_contents = resource_text('hook-tmpl')
         new_contents = pre_commit_contents.replace(
-            CURRENT_HASH, PRIOR_HASHES[-1],
+            CURRENT_HASH.decode(), PRIOR_HASHES[-1].decode(),
         )
 
         os.makedirs(os.path.join(path, '.git/hooks'), exist_ok=True)