Repository: pre-commit/pre-commit

Issue:
 Hooks should have a way of specifying minimum pre-commit version to run
Obviously this is a bit of a chicken-and-egg problem until the feature exists, but afterwards this'll be useful.

For instance, the feature added in 0.6.6 (additional dependencies) would be useful to gate against older pre-commit versions where installation would be nonsensical.

This would be a fatal error that would require the user to upgrade pre-commit to use that hook.

I'd like to implement this first, and then change all of the mirror repositories to use the additional-deps approach.  This would alleviate the pains in #282 as well by eliminating the hacky mirroring technique that is currently required for node hooks.



Discussion:

Resolving patch:
diff --git a/pre_commit/clientlib/validate_manifest.py b/pre_commit/clientlib/validate_manifest.py
--- a/pre_commit/clientlib/validate_manifest.py
+++ b/pre_commit/clientlib/validate_manifest.py
@@ -23,6 +23,9 @@ class InvalidManifestError(ValueError):
             'exclude': {'type': 'string', 'default': '^$'},
             'language': {'type': 'string'},
             'language_version': {'type': 'string', 'default': 'default'},
+            'minimum_pre_commit_version': {
+                'type': 'string', 'default': '0.0.0',
+            },
             'files': {'type': 'string'},
             'stages': {
                 'type': 'array',
diff --git a/pre_commit/repository.py b/pre_commit/repository.py
--- a/pre_commit/repository.py
+++ b/pre_commit/repository.py
@@ -4,6 +4,7 @@
 import shutil
 from collections import defaultdict
 
+import pkg_resources
 from cached_property import cached_property
 
 from pre_commit import git
@@ -18,6 +19,10 @@
 
 logger = logging.getLogger('pre_commit')
 
+_pre_commit_version = pkg_resources.parse_version(
+    pkg_resources.get_distribution('pre-commit').version
+)
+
 
 class Repository(object):
     def __init__(self, repo_config, repo_path_getter):
@@ -71,6 +76,18 @@ def hooks(self):
                     )
                 )
                 exit(1)
+            hook_version = pkg_resources.parse_version(
+                self.manifest.hooks[hook['id']]['minimum_pre_commit_version'],
+            )
+            if hook_version > _pre_commit_version:
+                logger.error(
+                    'The hook `{0}` requires pre-commit version {1} but '
+                    'version {2} is installed.  '
+                    'Perhaps run `pip install --upgrade pre-commit`.'.format(
+                        hook['id'], hook_version, _pre_commit_version,
+                    )
+                )
+                exit(1)
         return tuple(
             (hook['id'], dict(self.manifest.hooks[hook['id']], **hook))
             for hook in self.repo_config['hooks']


Test patch:
diff --git a/testing/fixtures.py b/testing/fixtures.py
--- a/testing/fixtures.py
+++ b/testing/fixtures.py
@@ -1,10 +1,12 @@
 from __future__ import absolute_import
 from __future__ import unicode_literals
 
+import contextlib
 import io
 import os.path
 
 from aspy.yaml import ordered_dump
+from aspy.yaml import ordered_load
 
 import pre_commit.constants as C
 from pre_commit.clientlib.validate_config import CONFIG_JSON_SCHEMA
@@ -35,6 +37,17 @@ def make_repo(tempdir_factory, repo_source):
     return path
 
 
+@contextlib.contextmanager
+def modify_manifest(path):
+    """Modify the manifest yielded by this context to write to hooks.yaml."""
+    manifest_path = os.path.join(path, C.MANIFEST_FILE)
+    manifest = ordered_load(io.open(manifest_path).read())
+    yield manifest
+    with io.open(manifest_path, 'w') as manifest_file:
+        manifest_file.write(ordered_dump(manifest, **C.YAML_DUMP_KWARGS))
+    cmd_output('git', 'commit', '-am', 'update hooks.yaml', cwd=path)
+
+
 def config_with_local_hooks():
     return OrderedDict((
         ('repo', 'local'),
diff --git a/tests/manifest_test.py b/tests/manifest_test.py
--- a/tests/manifest_test.py
+++ b/tests/manifest_test.py
@@ -27,6 +27,7 @@ def test_manifest_contents(manifest):
         'id': 'bash_hook',
         'language': 'script',
         'language_version': 'default',
+        'minimum_pre_commit_version': '0.0.0',
         'name': 'Bash hook',
         'stages': [],
     }]
@@ -42,6 +43,7 @@ def test_hooks(manifest):
         'id': 'bash_hook',
         'language': 'script',
         'language_version': 'default',
+        'minimum_pre_commit_version': '0.0.0',
         'name': 'Bash hook',
         'stages': [],
     }
diff --git a/tests/repository_test.py b/tests/repository_test.py
--- a/tests/repository_test.py
+++ b/tests/repository_test.py
@@ -5,9 +5,11 @@
 import logging
 import os
 import os.path
+import re
 import shutil
 
 import mock
+import pkg_resources
 import pytest
 
 from pre_commit import five
@@ -24,6 +26,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_slowtests_false
 from testing.util import xfailif_no_pcre_support
 from testing.util import xfailif_windows_no_node
@@ -525,3 +528,33 @@ def test_hook_id_not_present(tempdir_factory, store, fake_log_handler):
         'Typo? Perhaps it is introduced in a newer version?  '
         'Often `pre-commit autoupdate` fixes this.'.format(path)
     )
+
+
+def test_too_new_version(tempdir_factory, store, fake_log_handler):
+    path = make_repo(tempdir_factory, 'script_hooks_repo')
+    with modify_manifest(path) as manifest:
+        manifest[0]['minimum_pre_commit_version'] = '999.0.0'
+    config = make_config_from_repo(path)
+    repo = Repository.create(config, store)
+    with pytest.raises(SystemExit):
+        repo.install()
+    msg = fake_log_handler.handle.call_args[0][0].msg
+    assert re.match(
+        r'^The hook `bash_hook` requires pre-commit version 999\.0\.0 but '
+        r'version \d+\.\d+\.\d+ is installed.  '
+        r'Perhaps run `pip install --upgrade pre-commit`\.$',
+        msg,
+    )
+
+
+@pytest.mark.parametrize(
+    'version',
+    ('0.1.0', pkg_resources.get_distribution('pre-commit').version),
+)
+def test_versions_ok(tempdir_factory, store, version):
+    path = make_repo(tempdir_factory, 'script_hooks_repo')
+    with modify_manifest(path) as manifest:
+        manifest[0]['minimum_pre_commit_version'] = version
+    config = make_config_from_repo(path)
+    # Should succeed
+    Repository.create(config, store).install()