Repository: pre-commit/pre-commit

Issue:
Windows: Node Support
This involves solving this ticket: https://github.com/ekalinin/nodeenv/issues/53

I've already started some work on this



Discussion:
Track https://github.com/ekalinin/nodeenv/issues/140 for progress

Hi, I would appreciate more detail about what is broken and why it relates to `nodeenv`.

I found an apparent bug with node on Windows which may not be related to this bug. I also found a solution which I will explain below.

To summarise, the cmd given to __popen is the wrong cmd, eg the wrong exe is found by pre-commit.

I am using `pre-commit 0.12.2` and the `eslint` mirror (http://github.com/pre-commit/mirrors-eslint), `v3.9.1`.

I installed `eslint` itself through `npm` and it resides in my `node_modules` directory within my project folder. `npm` is `3.10.10` and `eslint` is `v3.15.0`.

The relevant block of my `.pre-commit-config.yaml` looks like this:
```
- repo: git://github.com/pre-commit/mirrors-eslint
  sha: 'v3.9.1'
  hooks:
  - id: eslint
    language: system
```

I invoked `eslint` like this from the windows command prompt (same thing happens from gitbash):
`pre-commit run --run-all eslint`

Note that I am in my python `virtualenv` and `node_modules/.bin` is in my PATH.

The result was a `FileNotFoundError`, raised from `util.cmd_outpuit()` at the line `proc = __popen(cmd, **popen_kwargs)`.

The value of `cmd` was `('\\bin\\sh', 'C:\\<path to project>\\node_modules\\.bin\\eslint', '<js file for linting>', '<another js file for linting')`.

In my `node_modules` directory, I actually have the following files: `eslint` and `eslint.cmd`. Looking at the sourcecode of `parse_shebang.find_execitable()` I could see that there was a candidate list of filenames that were attempted to match based on the executable file extensions of the windows environment. The first item in the list was `eslint` without any extension, so it always matched. 

In order to make `eslint.cmd` match inside `parse_shebang.find_execitable()`, I altered the code.

With the altered code, the value of `cmd` was like this: `('C:\\<path to project>\\node_modules\\.bin\\eslint', '<js file for linting>', '<another js file for linting>')`.

When subsequently invoked `eslint` as before worked in both the windows cmd prompt and gitbash.

I will submit a pull request for this shortly. I assume the new logic for matching files will not cause other problems.



 
A lot to parse but I'll try my best :)

So this refers to first-class support for node projects -- the goal of pre-commit is you don't need any dependencies other than installing pre-commit.  It can then handle creating isolated node / ruby / python / etc. environments without requiring the user to globally install things.  Proper node support (from pre-commit's persepective) is to make `language: node` work without dependencies.

As for your hook configuration, you're not actually depending on the node environment that pre-commit may create so you may find a `local` configuration more convenient: http://pre-commit.com/#repository-local-hooks.  Something like this

```yaml
-   repo: local
    hooks:
    -   id: eslint
        name: eslint
        entry: eslint
        language: system
        files: \.js$
```

As for the PR, yeah that sounds fine.  I think windows also does a similar prioritization.
Thanks for the reply and explanation. Thanks for telling me about local hooks! I missed them in the docs and they would work much better for me.
neat, nodeenv has supported windows for a while.  _Unfortunately_ our tests choke on this a bit:

```
E           Errors:
E                * Install prebuilt node (8.1.3) ..... done.
E               You do not have sufficient privilege to perform this operation.
E               You do not have sufficient privilege to perform this operation.
E               Error: Failed to create nodejs.exe link
E                * Install npm.js (latest) ... Traceback (most recent call last):
E                 File "c:\python27\Lib\runpy.py", line 162, in _run_module_as_main
E                   "__main__", fname, loader, pkg_name)
E                 File "c:\python27\Lib\runpy.py", line 72, in _run_code
E                   exec code in run_globals
E                 File "C:\Users\Anthony\Desktop\git\pre-commit\venv\lib\site-packages\nodeenv.py", line 1277, in <modul
e>
E                   main()
E                 File "C:\Users\Anthony\Desktop\git\pre-commit\venv\lib\site-packages\nodeenv.py", line 1028, in main
E                   create_environment(env_dir, opt)
E                 File "C:\Users\Anthony\Desktop\git\pre-commit\venv\lib\site-packages\nodeenv.py", line 872, in create_
environment
E                   instfunc(env_dir, src_dir, opt)
E                 File "C:\Users\Anthony\Desktop\git\pre-commit\venv\lib\site-packages\nodeenv.py", line 742, in install
_npm_win
E                   zipf.extractall(src_dir)
E                 File "c:\python27\Lib\zipfile.py", line 1053, in extractall
E                   self.extract(zipinfo, path, pwd)
E                 File "c:\python27\Lib\zipfile.py", line 1041, in extract
E                   return self._extract_member(member, path, pwd)
E                 File "c:\python27\Lib\zipfile.py", line 1092, in _extract_member
E                   os.mkdir(targetpath)
E               WindowsError: [Error 206] The filename or extension is too long: 'C:\\temp\\a\\test_run_a_node_hook0\\0\
\.pre-commit\\repogflpmz\\node_env-default\\src\\npm-latest\\node_modules\\pacote\\node_modules\\make-fetch-happen\\node
_modules\\http-proxy-agent\\node_modules\\agent-base\\node_modules\\es6-promisify\\node_modules\\es6-promise\\dist'
```

I'm wondering if we pick a shorter tempdir that it'll work better?  Also wondering if this *just works* given my home directory depth.
Yeah even with significantly limiting the path depth, I'm still having [issues](https://github.com/ekalinin/nodeenv/issues/190)
See also https://github.com/npm/npm/issues/17663
Here's the new issue: https://github.com/npm/npm/issues/18978
Shouldn't enabling long paths help with this? Spoiler: they don't.
![policy-editor-long-paths](https://user-images.githubusercontent.com/597920/34855186-58af72ca-f70c-11e7-839e-5ab6339858c5.png)

I would hope so, but I've also seen that even with the setting enabled it still doesn't work (and I don't have a good reason for _why_) (maybe needs a system restart to take?)

I'm going to try poking this again with the reproduction I made in npm/npm#18978 and see what registry settings I can tweak.
Perhaps it's as simple as a missing `"\\?\"` prefix...I'm not a windows dev; guessing based on [msft docs](https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247%28v=vs.85%29.aspx?f=255&MSPPError=-2147217396).

Resolving patch:
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,6 +7,7 @@
 from pre_commit.envcontext import envcontext
 from pre_commit.envcontext import Var
 from pre_commit.languages import helpers
+from pre_commit.languages.python import bin_dir
 from pre_commit.util import clean_path_on_failure
 from pre_commit.util import cmd_output
 from pre_commit.xargs import xargs
@@ -17,10 +18,17 @@
 healthy = helpers.basic_healthy
 
 
-def get_env_patch(venv):  # pragma: windows no cover
+def _envdir(prefix, version):
+    directory = helpers.environment_dir(ENVIRONMENT_DIR, version)
+    return prefix.path(directory)
+
+
+def get_env_patch(venv):
     if sys.platform == 'cygwin':  # pragma: no cover
         _, win_venv, _ = cmd_output('cygpath', '-w', venv)
         install_prefix = r'{}\bin'.format(win_venv.strip())
+    elif sys.platform == 'win32':  # pragma: no cover
+        install_prefix = bin_dir(venv)
     else:
         install_prefix = venv
     return (
@@ -28,29 +36,26 @@ def get_env_patch(venv):  # pragma: windows no cover
         ('NPM_CONFIG_PREFIX', install_prefix),
         ('npm_config_prefix', install_prefix),
         ('NODE_PATH', os.path.join(venv, 'lib', 'node_modules')),
-        ('PATH', (os.path.join(venv, 'bin'), os.pathsep, Var('PATH'))),
+        ('PATH', (bin_dir(venv), os.pathsep, Var('PATH'))),
     )
 
 
 @contextlib.contextmanager
-def in_env(prefix, language_version):  # pragma: windows no cover
-    envdir = prefix.path(
-        helpers.environment_dir(ENVIRONMENT_DIR, language_version),
-    )
-    with envcontext(get_env_patch(envdir)):
+def in_env(prefix, language_version):
+    with envcontext(get_env_patch(_envdir(prefix, language_version))):
         yield
 
 
-def install_environment(
-        prefix, version, additional_dependencies,
-):  # pragma: windows no cover
+def install_environment(prefix, version, additional_dependencies):
     additional_dependencies = tuple(additional_dependencies)
     assert prefix.exists('package.json')
-    directory = helpers.environment_dir(ENVIRONMENT_DIR, version)
+    envdir = _envdir(prefix, version)
 
-    env_dir = prefix.path(directory)
-    with clean_path_on_failure(env_dir):
-        cmd = [sys.executable, '-m', 'nodeenv', '--prebuilt', env_dir]
+    # https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx?f=255&MSPPError=-2147217396#maxpath
+    if sys.platform == 'win32':  # pragma: no cover
+        envdir = '\\\\?\\' + os.path.normpath(envdir)
+    with clean_path_on_failure(envdir):
+        cmd = [sys.executable, '-m', 'nodeenv', '--prebuilt', envdir]
         if version != 'default':
             cmd.extend(['-n', version])
         cmd_output(*cmd)
@@ -62,6 +67,6 @@ def install_environment(
             )
 
 
-def run_hook(prefix, hook, file_args):  # pragma: windows no cover
+def run_hook(prefix, hook, file_args):
     with in_env(prefix, hook['language_version']):
         return xargs(helpers.to_cmd(hook), file_args)


Test patch:
diff --git a/testing/resources/node_0_11_8_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/node_0_11_8_hooks_repo/.pre-commit-hooks.yaml
deleted file mode 100644
--- a/testing/resources/node_0_11_8_hooks_repo/.pre-commit-hooks.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
--   id: node-11-8-hook
-    name: Node 0.11.8 hook
-    entry: node-11-8-hook
-    language: node
-    language_version: 0.11.8
-    files: \.js$
diff --git a/testing/resources/node_0_11_8_hooks_repo/package.json b/testing/resources/node_0_11_8_hooks_repo/package.json
deleted file mode 100644
--- a/testing/resources/node_0_11_8_hooks_repo/package.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
-    "name": "node-11-8-hook",
-    "version": "0.0.1",
-    "bin": {"node-11-8-hook": "./bin/main.js"}
-}
diff --git a/testing/resources/node_versioned_hooks_repo/.pre-commit-hooks.yaml b/testing/resources/node_versioned_hooks_repo/.pre-commit-hooks.yaml
new file mode 100644
--- /dev/null
+++ b/testing/resources/node_versioned_hooks_repo/.pre-commit-hooks.yaml
@@ -0,0 +1,6 @@
+-   id: versioned-node-hook
+    name: Versioned node hook
+    entry: versioned-node-hook
+    language: node
+    language_version: 9.3.0
+    files: \.js$
diff --git a/testing/resources/node_0_11_8_hooks_repo/bin/main.js b/testing/resources/node_versioned_hooks_repo/bin/main.js
similarity index 100%
rename from testing/resources/node_0_11_8_hooks_repo/bin/main.js
rename to testing/resources/node_versioned_hooks_repo/bin/main.js
diff --git a/testing/resources/node_versioned_hooks_repo/package.json b/testing/resources/node_versioned_hooks_repo/package.json
new file mode 100644
--- /dev/null
+++ b/testing/resources/node_versioned_hooks_repo/package.json
@@ -0,0 +1,5 @@
+{
+    "name": "versioned-node-hook",
+    "version": "0.0.1",
+    "bin": {"versioned-node-hook": "./bin/main.js"}
+}
diff --git a/testing/util.py b/testing/util.py
--- a/testing/util.py
+++ b/testing/util.py
@@ -1,6 +1,7 @@
 from __future__ import unicode_literals
 
 import os.path
+import sys
 
 import pytest
 
@@ -42,9 +43,21 @@ def cmd_output_mocked_pre_commit_home(*args, **kwargs):
     reason='Ruby support not yet implemented on windows.',
 )
 
-xfailif_windows_no_node = pytest.mark.xfail(
-    os.name == 'nt',
-    reason='Node support not yet implemented on windows.',
+
+def broken_deep_listdir():  # pragma: no cover (platform specific)
+    if sys.platform != 'win32':
+        return False
+    try:
+        os.listdir(str('\\\\?\\') + os.path.abspath(str('.')))
+    except OSError:
+        return True
+    else:
+        return False
+
+
+xfailif_broken_deep_listdir = pytest.mark.xfail(
+    broken_deep_listdir(),
+    reason='Node on windows requires deep listdir',
 )
 
 
diff --git a/tests/repository_test.py b/tests/repository_test.py
--- a/tests/repository_test.py
+++ b/tests/repository_test.py
@@ -31,8 +31,8 @@
 from testing.util import get_resource_path
 from testing.util import skipif_cant_run_docker
 from testing.util import skipif_cant_run_swift
+from testing.util import xfailif_broken_deep_listdir
 from testing.util import xfailif_no_pcre_support
-from testing.util import xfailif_windows_no_node
 from testing.util import xfailif_windows_no_ruby
 
 
@@ -186,7 +186,7 @@ def test_run_a_docker_image_hook(tempdir_factory, store, hook_id):
     )
 
 
-@xfailif_windows_no_node
+@xfailif_broken_deep_listdir
 @pytest.mark.integration
 def test_run_a_node_hook(tempdir_factory, store):
     _test_hook_repo(
@@ -195,12 +195,12 @@ def test_run_a_node_hook(tempdir_factory, store):
     )
 
 
-@xfailif_windows_no_node
+@xfailif_broken_deep_listdir
 @pytest.mark.integration
 def test_run_versioned_node_hook(tempdir_factory, store):
     _test_hook_repo(
-        tempdir_factory, store, 'node_0_11_8_hooks_repo',
-        'node-11-8-hook', [os.devnull], b'v0.11.8\nHello World\n',
+        tempdir_factory, store, 'node_versioned_hooks_repo',
+        'versioned-node-hook', [os.devnull], b'v9.3.0\nHello World\n',
     )
 
 
@@ -505,7 +505,7 @@ def test_additional_ruby_dependencies_installed(
         assert 'tins' in output
 
 
-@xfailif_windows_no_node
+@xfailif_broken_deep_listdir
 @pytest.mark.integration
 def test_additional_node_dependencies_installed(
         tempdir_factory, store,