Repository: pre-commit/pre-commit

Issue:
rbenv FileNotFoundError when installing markdownlint hook
I am trying to use https://github.com/markdownlint/markdownlint to lint my markdown files. I checked the issue tracker and [don't seem to see any related issues](https://github.com/pre-commit/pre-commit/issues?q=FileNotFoundError+rbenv). 

I have `.rbenv` installed on my system and am wondering if that could be interfering with `pre-commit`?

### .pre-commit-config.yaml
```
repos:
-   repo: https://github.com/markdownlint/markdownlint
    rev: master
    hooks:
    -   id: markdownlint
```

### version information

```
pre-commit version: 2.7.1
sys.version:
    3.9.0 (default, Oct  9 2020, 16:41:50) 
    [Clang 12.0.0 (clang-1200.0.32.2)]
sys.executable: /usr/local/Cellar/pre-commit/2.7.1_1/libexec/bin/python
os.name: posix
sys.platform: darwin
```

### error information

```
An unexpected error has occurred: FileNotFoundError: [Errno 2] No such file or directory: '/Users/adithyabalaji/.cache/pre-commit/repo09b5y8iv/rbenv-system/.install_state_v1staging'
```

```
Traceback (most recent call last):
  File "/usr/local/Cellar/pre-commit/2.7.1_1/libexec/lib/python3.9/site-packages/pre_commit/error_handler.py", line 63, in error_handler
    yield
  File "/usr/local/Cellar/pre-commit/2.7.1_1/libexec/lib/python3.9/site-packages/pre_commit/main.py", line 390, in main
    return run(args.config, store, args)
  File "/usr/local/Cellar/pre-commit/2.7.1_1/libexec/lib/python3.9/site-packages/pre_commit/commands/run.py", line 388, in run
    install_hook_envs(hooks, store)
  File "/usr/local/Cellar/pre-commit/2.7.1_1/libexec/lib/python3.9/site-packages/pre_commit/repository.py", line 206, in install_hook_envs
    _hook_install(hook)
  File "/usr/local/Cellar/pre-commit/2.7.1_1/libexec/lib/python3.9/site-packages/pre_commit/repository.py", line 92, in _hook_install
    _write_state(hook.prefix, venv, _state(hook.additional_dependencies))
  File "/usr/local/Cellar/pre-commit/2.7.1_1/libexec/lib/python3.9/site-packages/pre_commit/repository.py", line 48, in _write_state
    with open(staging, 'w') as state_file:
FileNotFoundError: [Errno 2] No such file or directory: '/Users/adithyabalaji/.cache/pre-commit/repo09b5y8iv/rbenv-system/.install_state_v1staging'

```


Discussion:
hmm that's interesting, can you show `env | grep -i rbenv` or `env | grep -i ruby`
@asottile You know what, that hint led me to figure out the issue after banging my head against it for like an hour.

Since I had installed `rbenv` using brew, I had a `eval "$(rbenv init -)"` in my `.zshrc`. The addition of the shim directory to the `$PATH` seemed to be the root cause of the problem. I'm still not entirely certain why that is the case.

Before:
```
$ env | grep -i rbenv
PATH=/Users/adithyabalaji/.rbenv/shims:/Users/adithyabalaji/.cargo/bin:/Users/adithyabalaji/.jenv/shims:/Users/adithyabalaji/.nodenv/shims:/Users/adithyabalaji/.rbenv/shims:/usr/local/Cellar/pyenv-virtualenv/1.1.5/shims:/Users/adithyabalaji/.pyenv/shims:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/TeX/texbin:/opt/X11/bin
RBENV_SHELL=zsh
```

After (removing the extra init, it's no longer in my PATH
```
$ env | grep -i rbenv
~
```

When the shim directory is not explicitly in my PATH the install runs smoothly.
interesting, it should probably still work even if you use rbenv, I'll see what I can do
Okay, thanks. For now, this workaround unblocks me. But, I'll keep an eye on this issue!

Resolving patch:
diff --git a/pre_commit/languages/helpers.py b/pre_commit/languages/helpers.py
--- a/pre_commit/languages/helpers.py
+++ b/pre_commit/languages/helpers.py
@@ -1,6 +1,7 @@
 import multiprocessing
 import os
 import random
+import re
 from typing import Any
 from typing import List
 from typing import Optional
@@ -10,6 +11,7 @@
 from typing import TYPE_CHECKING
 
 import pre_commit.constants as C
+from pre_commit import parse_shebang
 from pre_commit.hook import Hook
 from pre_commit.prefix import Prefix
 from pre_commit.util import cmd_output_b
@@ -20,6 +22,25 @@
 
 FIXED_RANDOM_SEED = 1542676187
 
+SHIMS_RE = re.compile(r'[/\\]shims[/\\]')
+
+
+def exe_exists(exe: str) -> bool:
+    found = parse_shebang.find_executable(exe)
+    if found is None:  # exe exists
+        return False
+
+    homedir = os.path.expanduser('~')
+    try:
+        common: Optional[str] = os.path.commonpath((found, homedir))
+    except ValueError:  # on windows, different drives raises ValueError
+        common = None
+
+    return (
+        not SHIMS_RE.search(found) and  # it is not in a /shims/ directory
+        common != homedir  # it is not in the home directory
+    )
+
 
 def run_setup_cmd(prefix: Prefix, cmd: Tuple[str, ...]) -> None:
     cmd_output_b(*cmd, cwd=prefix.prefix_dir)
diff --git a/pre_commit/languages/node.py b/pre_commit/languages/node.py
--- a/pre_commit/languages/node.py
+++ b/pre_commit/languages/node.py
@@ -7,7 +7,6 @@
 from typing import Tuple
 
 import pre_commit.constants as C
-from pre_commit import parse_shebang
 from pre_commit.envcontext import envcontext
 from pre_commit.envcontext import PatchesT
 from pre_commit.envcontext import UNSET
@@ -31,7 +30,7 @@ def get_default_version() -> str:
         return C.DEFAULT
     # if node is already installed, we can save a bunch of setup time by
     # using the installed version
-    elif all(parse_shebang.find_executable(exe) for exe in ('node', 'npm')):
+    elif all(helpers.exe_exists(exe) for exe in ('node', 'npm')):
         return 'system'
     else:
         return C.DEFAULT
diff --git a/pre_commit/languages/ruby.py b/pre_commit/languages/ruby.py
--- a/pre_commit/languages/ruby.py
+++ b/pre_commit/languages/ruby.py
@@ -8,7 +8,6 @@
 from typing import Tuple
 
 import pre_commit.constants as C
-from pre_commit import parse_shebang
 from pre_commit.envcontext import envcontext
 from pre_commit.envcontext import PatchesT
 from pre_commit.envcontext import UNSET
@@ -26,7 +25,7 @@
 
 @functools.lru_cache(maxsize=1)
 def get_default_version() -> str:
-    if all(parse_shebang.find_executable(exe) for exe in ('ruby', 'gem')):
+    if all(helpers.exe_exists(exe) for exe in ('ruby', 'gem')):
         return 'system'
     else:
         return C.DEFAULT


Test patch:
diff --git a/tests/languages/helpers_test.py b/tests/languages/helpers_test.py
--- a/tests/languages/helpers_test.py
+++ b/tests/languages/helpers_test.py
@@ -1,17 +1,60 @@
 import multiprocessing
-import os
+import os.path
 import sys
 from unittest import mock
 
 import pytest
 
 import pre_commit.constants as C
+from pre_commit import parse_shebang
 from pre_commit.languages import helpers
 from pre_commit.prefix import Prefix
 from pre_commit.util import CalledProcessError
 from testing.auto_namedtuple import auto_namedtuple
 
 
+@pytest.fixture
+def find_exe_mck():
+    with mock.patch.object(parse_shebang, 'find_executable') as mck:
+        yield mck
+
+
+@pytest.fixture
+def homedir_mck():
+    def fake_expanduser(pth):
+        assert pth == '~'
+        return os.path.normpath('/home/me')
+
+    with mock.patch.object(os.path, 'expanduser', fake_expanduser):
+        yield
+
+
+def test_exe_exists_does_not_exist(find_exe_mck, homedir_mck):
+    find_exe_mck.return_value = None
+    assert helpers.exe_exists('ruby') is False
+
+
+def test_exe_exists_exists(find_exe_mck, homedir_mck):
+    find_exe_mck.return_value = os.path.normpath('/usr/bin/ruby')
+    assert helpers.exe_exists('ruby') is True
+
+
+def test_exe_exists_false_if_shim(find_exe_mck, homedir_mck):
+    find_exe_mck.return_value = os.path.normpath('/foo/shims/ruby')
+    assert helpers.exe_exists('ruby') is False
+
+
+def test_exe_exists_false_if_homedir(find_exe_mck, homedir_mck):
+    find_exe_mck.return_value = os.path.normpath('/home/me/somedir/ruby')
+    assert helpers.exe_exists('ruby') is False
+
+
+def test_exe_exists_commonpath_raises_ValueError(find_exe_mck, homedir_mck):
+    find_exe_mck.return_value = os.path.normpath('/usr/bin/ruby')
+    with mock.patch.object(os.path, 'commonpath', side_effect=ValueError):
+        assert helpers.exe_exists('ruby') is True
+
+
 def test_basic_get_default_version():
     assert helpers.basic_get_default_version() == C.DEFAULT