Repository: pre-commit/pre-commit (python)

Issue:
Hooks pre-fetching
Hi,

I want to suggest introducing a command for downloading all required hooks forcefully. The use case is simple:
1. I pack everything needed for testing into container
2. I deploy that into CI
3. It gets built and tests are ran

I'd like to separate fetching of the hooks and pre-installing them into a build step. This would allow me use caching for containers, saving time for tests.

P.S. Perhaps it's needed to add some argument to `run` command, so that it won't try downloading stuff when executing tests.

Pull request description:
Add an install-hooks command (similar to install --install-hooks).  Resolves #456

Patch:
diff --git a/pre_commit/commands/install_uninstall.py b/pre_commit/commands/install_uninstall.py
index 1277ec3..069fb9d 100644
--- a/pre_commit/commands/install_uninstall.py
+++ b/pre_commit/commands/install_uninstall.py
@@ -82,12 +82,16 @@ def install(runner, overwrite=False, hooks=False, hook_type='pre-commit'):
 
     # If they requested we install all of the hooks, do so.
     if hooks:
-        for repository in runner.repositories:
-            repository.require_installed()
+        install_hooks(runner)
 
     return 0
 
 
+def install_hooks(runner):
+    for repository in runner.repositories:
+        repository.require_installed()
+
+
 def uninstall(runner, hook_type='pre-commit'):
     """Uninstall the pre-commit hooks."""
     hook_path = runner.get_hook_path(hook_type)
diff --git a/pre_commit/main.py b/pre_commit/main.py
index e110120..3631197 100644
--- a/pre_commit/main.py
+++ b/pre_commit/main.py
@@ -12,6 +12,7 @@ from pre_commit import git
 from pre_commit.commands.autoupdate import autoupdate
 from pre_commit.commands.clean import clean
 from pre_commit.commands.install_uninstall import install
+from pre_commit.commands.install_uninstall import install_hooks
 from pre_commit.commands.install_uninstall import uninstall
 from pre_commit.commands.run import run
 from pre_commit.error_handler import error_handler
@@ -78,6 +79,17 @@ def main(argv=None):
         default='pre-commit',
     )
 
+    install_hooks_parser = subparsers.add_parser(
+        'install-hooks',
+        help=(
+            'Install hook environemnts for all environemnts in the config '
+            'file.  You may find `pre-commit install --install-hooks` more '
+            'useful.'
+        ),
+    )
+    _add_color_option(install_hooks_parser)
+    _add_config_option(install_hooks_parser)
+
     uninstall_parser = subparsers.add_parser(
         'uninstall', help='Uninstall the pre-commit script.',
     )
@@ -171,6 +183,8 @@ def main(argv=None):
                 runner, overwrite=args.overwrite, hooks=args.install_hooks,
                 hook_type=args.hook_type,
             )
+        elif args.command == 'install-hooks':
+            return install_hooks(runner)
         elif args.command == 'uninstall':
             return uninstall(runner, hook_type=args.hook_type)
         elif args.command == 'clean':


Test patch:
diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py
index 5bae08e..9136a0c 100644
--- a/tests/commands/install_uninstall_test.py
+++ b/tests/commands/install_uninstall_test.py
@@ -13,6 +13,7 @@ import mock
 import pre_commit.constants as C
 from pre_commit.commands.install_uninstall import IDENTIFYING_HASH
 from pre_commit.commands.install_uninstall import install
+from pre_commit.commands.install_uninstall import install_hooks
 from pre_commit.commands.install_uninstall import is_our_pre_commit
 from pre_commit.commands.install_uninstall import is_previous_pre_commit
 from pre_commit.commands.install_uninstall import PREVIOUS_IDENTIFYING_HASHES
@@ -460,6 +461,20 @@ def test_installs_hooks_with_hooks_True(
         assert PRE_INSTALLED.match(output)
 
 
+def test_install_hooks_command(tempdir_factory, mock_out_store_directory):
+    path = make_consuming_repo(tempdir_factory, 'script_hooks_repo')
+    with cwd(path):
+        runner = Runner(path, C.CONFIG_FILE)
+        install(runner)
+        install_hooks(runner)
+        ret, output = _get_commit_output(
+            tempdir_factory, pre_commit_home=mock_out_store_directory,
+        )
+
+        assert ret == 0
+        assert PRE_INSTALLED.match(output)
+
+
 def test_installed_from_venv(tempdir_factory):
     path = make_consuming_repo(tempdir_factory, 'script_hooks_repo')
     with cwd(path):
diff --git a/tests/main_test.py b/tests/main_test.py
index 86b6dcd..906d6f3 100644
--- a/tests/main_test.py
+++ b/tests/main_test.py
@@ -15,17 +15,19 @@ from testing.auto_namedtuple import auto_namedtuple
 @pytest.yield_fixture
 def mock_commands():
     with mock.patch.object(main, 'autoupdate') as autoupdate_mock:
-        with mock.patch.object(main, 'clean') as clean_mock:
+        with mock.patch.object(main, 'install_hooks') as install_hooks_mock:
             with mock.patch.object(main, 'install') as install_mock:
                 with mock.patch.object(main, 'uninstall') as uninstall_mock:
                     with mock.patch.object(main, 'run') as run_mock:
-                        yield auto_namedtuple(
-                            autoupdate_mock=autoupdate_mock,
-                            clean_mock=clean_mock,
-                            install_mock=install_mock,
-                            uninstall_mock=uninstall_mock,
-                            run_mock=run_mock,
-                        )
+                        with mock.patch.object(main, 'clean') as clean_mock:
+                            yield auto_namedtuple(
+                                autoupdate_mock=autoupdate_mock,
+                                clean_mock=clean_mock,
+                                install_mock=install_mock,
+                                install_hooks_mock=install_hooks_mock,
+                                uninstall_mock=uninstall_mock,
+                                run_mock=run_mock,
+                            )
 
 
 class CalledExit(Exception):
@@ -121,6 +123,12 @@ def test_run_command(mock_commands):
     assert_only_one_mock_called(mock_commands)
 
 
+def test_install_hooks_command(mock_commands):
+    main.main(('install-hooks',))
+    assert mock_commands.install_hooks_mock.call_count == 1
+    assert_only_one_mock_called(mock_commands)
+
+
 def test_no_commands_run_command(mock_commands):
     main.main([])
     assert mock_commands.run_mock.call_count == 1