Repository: pre-commit/pre-commit

Issue:
Add Support for MSYS2 in Windows
Thanks @asottile and all the other contributors for the great tool! ❤️ 👍 

I am one of the maintainers for [Gaphor](https://github.com/gaphor/gaphor), and we make heavy use of pre-commit as part of our work flow. It is also built in GTK, and we have to use MSYS2 to build the app for Windows. I know it is a pretty niche environment to support, but it would be really great if we could make it happen.

## Current Behavior
### version information

```
pre-commit version: 2.7.1
sys.version:
    3.8.5 (default, Sep  7 2020, 18:26:56)  [GCC 10.2.0 64 bit (AMD64)]
sys.executable: c:/tools/msys64/home/dyeaw/gaphor/.venv/bin/python.exe
os.name: nt
sys.platform: win32
```

### error information

```
An unexpected error has occurred: FileNotFoundError: [WinError 3] The system cannot find the path specified: '/home/dyeaw/gaphor'
```

```
Traceback (most recent call last):
  File "C:/tools/msys64/home/dyeaw/gaphor/.venv/lib/python3.8/site-packages/pre_commit/error_handler.py", line 63, in error_handler
    yield
  File "C:/tools/msys64/home/dyeaw/gaphor/.venv/lib/python3.8/site-packages/pre_commit/main.py", line 343, in main
    _adjust_args_and_chdir(args)
  File "C:/tools/msys64/home/dyeaw/gaphor/.venv/lib/python3.8/site-packages/pre_commit/main.py", line 163, in _adjust_args_and_chdir
    os.chdir(toplevel)
FileNotFoundError: [WinError 3] The system cannot find the path specified: '/home/dyeaw/gaphor'
```

## Updates Needed
- [ ] Update pre-commit to make use of the `cygpath` command to convert the path to the standard Windows path format after it gets the top-level path from git.
- [ ] Remove cygwin warnings about incompatible Python+git versions if in the MSYS2 environment
- [ ] Make upstream updates to setuptools_scm to patch additional path conversions for similar things as 1 above.

My plan, as long is everyone is good with adding support for this environment, is to submit a PR for the first two, and then I'll start to work with the setuptools_scm team to see if I can fix the third one as well.

Discussion:
danyeaw: @asottile I tried this out, and it does move the cygwin mismatch check up earlier. I don't think it fixes the issue though, it isn't that I am missing the error because of the other issues, the problem is that I shouldn't be getting this warning at all. I am not running in cygwin, I am in MSYS2 and my git and Python aren't mismatched.
asottile: they are mismatched, one is acting like windows and the other is acting like posix -- that's the entire point of the warning

Patch:
diff --git a/pre_commit/clientlib.py b/pre_commit/clientlib.py
index 8dfa947..87679bf 100644
--- a/pre_commit/clientlib.py
+++ b/pre_commit/clientlib.py
@@ -13,7 +13,7 @@ from identify.identify import ALL_TAGS
 
 import pre_commit.constants as C
 from pre_commit.color import add_color_option
-from pre_commit.error_handler import FatalError
+from pre_commit.errors import FatalError
 from pre_commit.languages.all import all_languages
 from pre_commit.logging_handler import logging_handler
 from pre_commit.util import parse_version
diff --git a/pre_commit/error_handler.py b/pre_commit/error_handler.py
index 009f6d9..afacab9 100644
--- a/pre_commit/error_handler.py
+++ b/pre_commit/error_handler.py
@@ -7,14 +7,11 @@ from typing import Generator
 
 import pre_commit.constants as C
 from pre_commit import output
+from pre_commit.errors import FatalError
 from pre_commit.store import Store
 from pre_commit.util import force_bytes
 
 
-class FatalError(RuntimeError):
-    pass
-
-
 def _log_and_exit(msg: str, exc: BaseException, formatted: str) -> None:
     error_msg = f'{msg}: {type(exc).__name__}: '.encode() + force_bytes(exc)
     output.write_line_b(error_msg)
diff --git a/pre_commit/errors.py b/pre_commit/errors.py
new file mode 100644
index 0000000..f84d3f1
--- /dev/null
+++ b/pre_commit/errors.py
@@ -0,0 +1,2 @@
+class FatalError(RuntimeError):
+    pass
diff --git a/pre_commit/git.py b/pre_commit/git.py
index 576bef8..ca30eaa 100644
--- a/pre_commit/git.py
+++ b/pre_commit/git.py
@@ -6,6 +6,8 @@ from typing import List
 from typing import Optional
 from typing import Set
 
+from pre_commit.errors import FatalError
+from pre_commit.util import CalledProcessError
 from pre_commit.util import cmd_output
 from pre_commit.util import cmd_output_b
 from pre_commit.util import EnvironT
@@ -43,7 +45,21 @@ def no_git_env(_env: Optional[EnvironT] = None) -> Dict[str, str]:
 
 
 def get_root() -> str:
-    return cmd_output('git', 'rev-parse', '--show-toplevel')[1].strip()
+    try:
+        root = cmd_output('git', 'rev-parse', '--show-toplevel')[1].strip()
+    except CalledProcessError:
+        raise FatalError(
+            'git failed. Is it installed, and are you in a Git repository '
+            'directory?',
+        )
+    else:
+        if root == '':  # pragma: no cover (old git)
+            raise FatalError(
+                'git toplevel unexpectedly empty! make sure you are not '
+                'inside the `.git` directory of your repository.',
+            )
+        else:
+            return root
 
 
 def get_git_dir(git_root: str = '.') -> str:
@@ -181,7 +197,7 @@ def check_for_cygwin_mismatch() -> None:
     """See https://github.com/pre-commit/pre-commit/issues/354"""
     if sys.platform in ('cygwin', 'win32'):  # pragma: no cover (windows)
         is_cygwin_python = sys.platform == 'cygwin'
-        toplevel = cmd_output('git', 'rev-parse', '--show-toplevel')[1]
+        toplevel = get_root()
         is_cygwin_git = toplevel.startswith('/')
 
         if is_cygwin_python ^ is_cygwin_git:
diff --git a/pre_commit/main.py b/pre_commit/main.py
index 8647960..c1eb104 100644
--- a/pre_commit/main.py
+++ b/pre_commit/main.py
@@ -23,10 +23,8 @@ from pre_commit.commands.run import run
 from pre_commit.commands.sample_config import sample_config
 from pre_commit.commands.try_repo import try_repo
 from pre_commit.error_handler import error_handler
-from pre_commit.error_handler import FatalError
 from pre_commit.logging_handler import logging_handler
 from pre_commit.store import Store
-from pre_commit.util import CalledProcessError
 
 
 logger = logging.getLogger('pre_commit')
@@ -146,21 +144,8 @@ def _adjust_args_and_chdir(args: argparse.Namespace) -> None:
     if args.command == 'try-repo' and os.path.exists(args.repo):
         args.repo = os.path.abspath(args.repo)
 
-    try:
-        toplevel = git.get_root()
-    except CalledProcessError:
-        raise FatalError(
-            'git failed. Is it installed, and are you in a Git repository '
-            'directory?',
-        )
-    else:
-        if toplevel == '':  # pragma: no cover (old git)
-            raise FatalError(
-                'git toplevel unexpectedly empty! make sure you are not '
-                'inside the `.git` directory of your repository.',
-            )
-        else:
-            os.chdir(toplevel)
+    toplevel = git.get_root()
+    os.chdir(toplevel)
 
     args.config = os.path.relpath(args.config)
     if args.command in {'run', 'try-repo'}:
@@ -339,11 +324,11 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
         parser.parse_args(['--help'])
 
     with error_handler(), logging_handler(args.color):
+        git.check_for_cygwin_mismatch()
+
         if args.command not in COMMANDS_NO_GIT:
             _adjust_args_and_chdir(args)
 
-        git.check_for_cygwin_mismatch()
-
         store = Store()
         store.mark_config_used(args.config)
 


Test patch:
diff --git a/tests/error_handler_test.py b/tests/error_handler_test.py
index 5dc0850..804701f 100644
--- a/tests/error_handler_test.py
+++ b/tests/error_handler_test.py
@@ -7,6 +7,7 @@ import pytest
 import re_assert
 
 from pre_commit import error_handler
+from pre_commit.errors import FatalError
 from pre_commit.store import Store
 from pre_commit.util import CalledProcessError
 from testing.util import cmd_output_mocked_pre_commit_home
@@ -26,7 +27,7 @@ def test_error_handler_no_exception(mocked_log_and_exit):
 
 
 def test_error_handler_fatal_error(mocked_log_and_exit):
-    exc = error_handler.FatalError('just a test')
+    exc = FatalError('just a test')
     with error_handler.error_handler():
         raise exc
 
@@ -44,7 +45,7 @@ def test_error_handler_fatal_error(mocked_log_and_exit):
         r'  File ".+tests.error_handler_test.py", line \d+, '
         r'in test_error_handler_fatal_error\n'
         r'    raise exc\n'
-        r'(pre_commit\.error_handler\.)?FatalError: just a test\n',
+        r'(pre_commit\.errors\.)?FatalError: just a test\n',
     )
     pattern.assert_matches(mocked_log_and_exit.call_args[0][2])
 
@@ -99,11 +100,11 @@ def test_log_and_exit(cap_out, mock_store_dir):
     tb = (
         'Traceback (most recent call last):\n'
         '  File "<stdin>", line 2, in <module>\n'
-        'pre_commit.error_handler.FatalError: hai\n'
+        'pre_commit.errors.FatalError: hai\n'
     )
 
     with pytest.raises(SystemExit):
-        error_handler._log_and_exit('msg', error_handler.FatalError('hai'), tb)
+        error_handler._log_and_exit('msg', FatalError('hai'), tb)
 
     printed = cap_out.get()
     log_file = os.path.join(mock_store_dir, 'pre-commit.log')
@@ -133,7 +134,7 @@ def test_log_and_exit(cap_out, mock_store_dir):
             r'```\n'
             r'Traceback \(most recent call last\):\n'
             r'  File "<stdin>", line 2, in <module>\n'
-            r'pre_commit\.error_handler\.FatalError: hai\n'
+            r'pre_commit\.errors\.FatalError: hai\n'
             r'```\n',
         )
         pattern.assert_matches(logged)
diff --git a/tests/main_test.py b/tests/main_test.py
index f7abeeb..6738df6 100644
--- a/tests/main_test.py
+++ b/tests/main_test.py
@@ -6,7 +6,7 @@ import pytest
 
 import pre_commit.constants as C
 from pre_commit import main
-from pre_commit.error_handler import FatalError
+from pre_commit.errors import FatalError
 from testing.auto_namedtuple import auto_namedtuple