Repository: pre-commit/pre-commit
Pull request #459 (2017-01-03 21:49:56)

Docker hook support for pre-commit
This adds support for docker-based hooks.

Current expectations:
1. The hook should not be sad that we overwrite everything in /src by the cwd into it as a volume
2. entry in your hooks config is the cmd entrypoint inside the container

@asottile  see that same repo I sent you as an example.


Happy to add tests if we agree that this is sane

v2 might want this to clean up after itself.


Patch:
diff --git a/.travis.yml b/.travis.yml
index c5f989b..a01003c 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -20,7 +20,9 @@ before_install:
     - git --version
 after_success:
     - coveralls
-sudo: false
+sudo: required
+services:
+    - docker
 cache:
     directories:
         - $HOME/.cache/pip
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a517090..1e0b246 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,7 +2,7 @@
 
 ## Local development
 
-- The tests depend on having at least the following installed (possibly not
+- The complete test suite depends on having at least the following installed (possibly not
   a complete list)
   - git (A sufficiently newer version is required to run pre-push tests)
   - python
@@ -10,6 +10,7 @@
   - python3.5 (Required by a test which checks different python versions)
   - tox (or virtualenv)
   - ruby + gem
+  - docker
 
 ### Setting up an environemnt
 
@@ -63,7 +64,7 @@ function_call(
 ```
 
 Some notable features:
-- The intial parenthese is at the end of the line
+- The initial parenthesis is at the end of the line
 - Parameters are indented one indentation level further than the function name
 - The last parameter contains a trailing comma (This helps make `git blame`
   more accurate and reduces merge conflicts when adding / removing parameters).
diff --git a/pre_commit/commands/install_uninstall.py b/pre_commit/commands/install_uninstall.py
index 069fb9d..1277ec3 100644
--- a/pre_commit/commands/install_uninstall.py
+++ b/pre_commit/commands/install_uninstall.py
@@ -82,16 +82,12 @@ def install(runner, overwrite=False, hooks=False, hook_type='pre-commit'):
 
     # If they requested we install all of the hooks, do so.
     if hooks:
-        install_hooks(runner)
+        for repository in runner.repositories:
+            repository.require_installed()
 
     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/languages/all.py b/pre_commit/languages/all.py
index 40c2313..a517ee8 100644
--- a/pre_commit/languages/all.py
+++ b/pre_commit/languages/all.py
@@ -1,5 +1,6 @@
 from __future__ import unicode_literals
 
+from pre_commit.languages import docker
 from pre_commit.languages import node
 from pre_commit.languages import pcre
 from pre_commit.languages import python
@@ -40,6 +41,7 @@ from pre_commit.languages import system
 #     """
 
 languages = {
+    'docker': docker,
     'node': node,
     'pcre': pcre,
     'python': python,
diff --git a/pre_commit/languages/docker.py b/pre_commit/languages/docker.py
new file mode 100644
index 0000000..b2fed46
--- /dev/null
+++ b/pre_commit/languages/docker.py
@@ -0,0 +1,90 @@
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import hashlib
+import os
+
+from pre_commit import five
+from pre_commit.languages import helpers
+from pre_commit.util import CalledProcessError
+from pre_commit.util import clean_path_on_failure
+from pre_commit.util import cmd_output
+from pre_commit.xargs import xargs
+
+
+ENVIRONMENT_DIR = 'docker'
+PRE_COMMIT_LABEL = 'PRE_COMMIT'
+
+
+def md5(s):
+    return hashlib.md5(five.to_bytes(s)).hexdigest()
+
+
+def docker_tag(repo_cmd_runner):
+    return 'pre-commit-{}'.format(
+        md5(os.path.basename(repo_cmd_runner.path()))
+    ).lower()
+
+
+def docker_is_running():
+    try:
+        return cmd_output('docker', 'ps')[0] == 0
+    except CalledProcessError:
+        return False
+
+
+def assert_docker_available():
+    assert docker_is_running(), (
+        'Docker is either not running or not configured in this environment'
+    )
+
+
+def build_docker_image(repo_cmd_runner):
+    cmd = (
+        'docker', 'build', '--pull',
+        '--tag', docker_tag(repo_cmd_runner),
+        '--label', PRE_COMMIT_LABEL,
+        '.'
+    )
+    helpers.run_setup_cmd(repo_cmd_runner, cmd)
+
+
+def install_environment(
+        repo_cmd_runner,
+        version='default',
+        additional_dependencies=(),
+):
+    assert repo_cmd_runner.exists('Dockerfile'), (
+        'No Dockerfile was found in the hook repository'
+    )
+    assert version == 'default', (
+        'Pre-commit does not support language_version for docker '
+    )
+    assert_docker_available()
+
+    directory = helpers.environment_dir(ENVIRONMENT_DIR, 'default')
+    os.mkdir(repo_cmd_runner.path(directory))
+
+    # Docker doesn't really have relevant disk environment, but pre-commit
+    # still needs to cleanup it's state files on failure
+    env_dir = repo_cmd_runner.path(directory)
+    with clean_path_on_failure(env_dir):
+        build_docker_image(repo_cmd_runner)
+
+
+def run_hook(repo_cmd_runner, hook, file_args):
+    assert_docker_available()
+    # Rebuild the docker image in case it has gone missing, as many people do
+    # automated cleanup of docker images.
+    build_docker_image(repo_cmd_runner)
+    cmd = (
+        'docker', 'run',
+        '--rm',
+        '-u', '{}:{}'.format(os.getuid(), os.getgid()),
+        '-v', '{}:/src:rw'.format(os.getcwd()),
+        '--workdir', '/src',
+        '--entrypoint', hook['entry'],
+        docker_tag(repo_cmd_runner)
+    )
+
+    return xargs(cmd + tuple(hook['args']), file_args)
diff --git a/pre_commit/main.py b/pre_commit/main.py
index 3631197..e110120 100644
--- a/pre_commit/main.py
+++ b/pre_commit/main.py
@@ -12,7 +12,6 @@ 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
@@ -79,17 +78,6 @@ 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.',
     )
@@ -183,8 +171,6 @@ 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/testing/resources/docker_hooks_repo/Dockerfile b/testing/resources/docker_hooks_repo/Dockerfile
new file mode 100644
index 0000000..acb5f54
--- /dev/null
+++ b/testing/resources/docker_hooks_repo/Dockerfile
@@ -0,0 +1,3 @@
+FROM cogniteev/echo
+
+CMD ["echo", "This is overwritten by the hooks.yaml 'entry'"]
diff --git a/testing/resources/docker_hooks_repo/hooks.yaml b/testing/resources/docker_hooks_repo/hooks.yaml
new file mode 100644
index 0000000..2c9a115
--- /dev/null
+++ b/testing/resources/docker_hooks_repo/hooks.yaml
@@ -0,0 +1,11 @@
+-   id: docker-hook
+    name: Docker test hook
+    entry: echo
+    language: docker
+    files: \.txt$
+
+-   id: docker-hook-failing
+    name: Docker test hook with nonzero exit code
+    entry: bork
+    language: docker
+    files: \.txt$
diff --git a/testing/util.py b/testing/util.py
index 10e8446..cf9dde9 100644
--- a/testing/util.py
+++ b/testing/util.py
@@ -6,6 +6,7 @@ import shutil
 import jsonschema
 import pytest
 
+from pre_commit.languages.docker import docker_is_running
 from pre_commit.util import cmd_output
 from pre_commit.util import cwd
 
@@ -57,6 +58,11 @@ def cmd_output_mocked_pre_commit_home(*args, **kwargs):
     return cmd_output(*args, env=env, **kwargs)
 
 
+skipif_cant_run_docker = pytest.mark.skipif(
+    docker_is_running() is False,
+    reason='Docker isn\'t running or can\'t  be accessed'
+)
+
 skipif_slowtests_false = pytest.mark.skipif(
     os.environ.get('slowtests') == 'false',
     reason='slowtests=false',
diff --git a/tests/commands/install_uninstall_test.py b/tests/commands/install_uninstall_test.py
index 9136a0c..5bae08e 100644
--- a/tests/commands/install_uninstall_test.py
+++ b/tests/commands/install_uninstall_test.py
@@ -13,7 +13,6 @@ 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
@@ -461,20 +460,6 @@ 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/languages/docker_test.py b/tests/languages/docker_test.py
new file mode 100644
index 0000000..6ca2ed5
--- /dev/null
+++ b/tests/languages/docker_test.py
@@ -0,0 +1,15 @@
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import mock
+
+from pre_commit.languages import docker
+from pre_commit.util import CalledProcessError
+
+
+def test_docker_is_running_process_error():
+    with mock.patch(
+        'pre_commit.languages.docker.cmd_output',
+        side_effect=CalledProcessError(*(None,) * 4)
+    ):
+        assert docker.docker_is_running() is False
diff --git a/tests/main_test.py b/tests/main_test.py
index 906d6f3..86b6dcd 100644
--- a/tests/main_test.py
+++ b/tests/main_test.py
@@ -15,19 +15,17 @@ 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, 'install_hooks') as install_hooks_mock:
+        with mock.patch.object(main, 'clean') as clean_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:
-                        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,
-                            )
+                        yield auto_namedtuple(
+                            autoupdate_mock=autoupdate_mock,
+                            clean_mock=clean_mock,
+                            install_mock=install_mock,
+                            uninstall_mock=uninstall_mock,
+                            run_mock=run_mock,
+                        )
 
 
 class CalledExit(Exception):
@@ -123,12 +121,6 @@ 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
diff --git a/tests/repository_test.py b/tests/repository_test.py
index f61ee88..5d8ed8a 100644
--- a/tests/repository_test.py
+++ b/tests/repository_test.py
@@ -29,6 +29,7 @@ from testing.fixtures import git_dir
 from testing.fixtures import make_config_from_repo
 from testing.fixtures import make_repo
 from testing.fixtures import modify_manifest
+from testing.util import skipif_cant_run_docker
 from testing.util import skipif_slowtests_false
 from testing.util import xfailif_no_pcre_support
 from testing.util import xfailif_windows_no_node
@@ -129,6 +130,29 @@ def test_versioned_python_hook(tempdir_factory, store):
     )
 
 
+@skipif_slowtests_false
+@skipif_cant_run_docker
+@pytest.mark.integration
+def test_run_a_docker_hook(tempdir_factory, store):
+    _test_hook_repo(
+        tempdir_factory, store, 'docker_hooks_repo',
+        'docker-hook',
+        ['Hello World from docker'], b'Hello World from docker\n',
+    )
+
+
+@skipif_slowtests_false
+@skipif_cant_run_docker
+@pytest.mark.integration
+def test_run_a_failing_docker_hook(tempdir_factory, store):
+    _test_hook_repo(
+        tempdir_factory, store, 'docker_hooks_repo',
+        'docker-hook-failing',
+        ['Hello World from docker'], b'',
+        expected_return_code=1
+    )
+
+
 @skipif_slowtests_false
 @xfailif_windows_no_node
 @pytest.mark.integration