Repository: pre-commit/pre-commit

Issue:
issue a warning if a mutable ref is used (`HEAD` / `stable` / `master` / etc.)
Perhaps nudging towards https://pre-commit.com/#using-the-latest-version-for-a-repository or `pre-commit autoupdate`


Discussion:
We use pre-commit to maintain a shared set of hooks for our 50+ repositories. Using a mutable ref is at the moment the solution that requires less effort from our developers, as there's no need to update the `.pre-commit-config.yaml` file and add unnecessary commits whenever the hooks are updated.
We're fully aware that this way hooks are not _stable_, but that's a drawback we are willing to accept in order to keep a faster pace when developing both our software and the hooks themselves.
What's the rationale behind enforcing a specific revision of the hooks? Ultimately, it should be a user's informed decision whether to stick with a specific revision or with a mutable one.
[this bit](https://pre-commit.com/#using-the-latest-version-for-a-repository) goes into a little detail about the rationale

The design enforces this idea for a couple reasons
- The first is that global hooks cause mass breaking upon improvement.  From experience, having a system-managed hook which upgrades without a commit often causes a large percentage of repositories to go from green-to-red.  The situation is even worse when that update requires a lock-step upgrade (old hook fails with new-hook's expected output, new-hook fails with old-hook's expected output).
- Repeatability.  You want to be able to check out any version of your code and have it build in a repeatable fashion.  This is especially true for your most basic checks (syntax / lint / etc.).  If linters are non-determinstic, you're much more likely to develop fatigue or turn them off.
- Cargo cult.  Since pre-commit caches based on the `rev` value, you'll immediately start drifting across all of your machines.  From practice, it's not uncommon that a pre-commit cache lives for many months or even years!  This causes a whole class of "works on my machine" and "oh yeah that thing sucks just run `pre-commit clean`" which is both a headache and a huge waste of precious developer time.
- It's trivially easy to upgrade.  `pre-commit autoupdate` was purpose built to make it easy to upgrade to the latest version.  Coupled with `pre-commit run --all-files`, an upgrade and reformat is a snap.
- Even with many repositories (we manage ~550 and growing with pre-commit at lyft) you can use tools to distribute these updates periodically -- for example [all-repos](https://github.com/asottile/all-repos) has an autofixer which does this [example PR](https://github.com/asottile/reorder_python_imports/pull/75/files)
While I do not use branches anywhere, and I am not against this feature, I would to mention few aspects:
* tags can mutate too, just like branches
* what makes a tag/branch mutable, its name? are we going to harcode warning for branches like `master` or `devel`?
* if we implement it, lets add a config item that can be used to disable this warning, so those adding it will confirm that they know what they are doing.
* I do see a perfectly fine use-case where someone could want to use a `stable-2.3` branch and avoid the need to update hundreds of repos just because they had a hotfix on it.
* there is a bug in `pre-commit autoupdate` which removes a revision and replaces it with last tag, even if the revision may be newer than the tag. I faced it myself where I was forced to used an unreleased version of ansible-lint which contained critical bugfixes.
- I'm not going to make this configurable
- that's not a bug and completely off topic, you want `--bleeding-edge`

Resolving patch:
diff --git a/pre_commit/clientlib.py b/pre_commit/clientlib.py
--- a/pre_commit/clientlib.py
+++ b/pre_commit/clientlib.py
@@ -1,6 +1,7 @@
 import argparse
 import functools
 import logging
+import re
 import shlex
 import sys
 from typing import Any
@@ -112,6 +113,25 @@ def validate_manifest_main(argv: Optional[Sequence[str]] = None) -> int:
 META = 'meta'
 
 
+# should inherit from cfgv.Conditional if sha support is dropped
+class WarnMutableRev(cfgv.ConditionalOptional):
+    def check(self, dct: Dict[str, Any]) -> None:
+        super().check(dct)
+
+        if self.key in dct:
+            rev = dct[self.key]
+
+            if '.' not in rev and not re.match(r'^[a-fA-F0-9]+$', rev):
+                logger.warning(
+                    f'The {self.key!r} field of repo {dct["repo"]!r} '
+                    f'appears to be a mutable reference '
+                    f'(moving tag / branch).  Mutable references are never '
+                    f'updated after first install and are not supported.  '
+                    f'See https://pre-commit.com/#using-the-latest-version-for-a-repository '  # noqa: E501
+                    f'for more details.',
+                )
+
+
 class OptionalSensibleRegex(cfgv.OptionalNoDefault):
     def check(self, dct: Dict[str, Any]) -> None:
         super().check(dct)
@@ -261,6 +281,14 @@ def warn_unknown_keys_repo(
     ),
 
     MigrateShaToRev(),
+    WarnMutableRev(
+        'rev',
+        cfgv.check_string,
+        '',
+        'repo',
+        cfgv.NotIn(LOCAL, META),
+        True,
+    ),
     cfgv.WarnAdditionalKeys(('repo', 'rev', 'hooks'), warn_unknown_keys_repo),
 )
 DEFAULT_LANGUAGE_VERSION = cfgv.Map(


Test patch:
diff --git a/tests/clientlib_test.py b/tests/clientlib_test.py
--- a/tests/clientlib_test.py
+++ b/tests/clientlib_test.py
@@ -180,6 +180,70 @@ def test_ci_key_must_be_map():
         cfgv.validate({'ci': 'invalid', 'repos': []}, CONFIG_SCHEMA)
 
 
+@pytest.mark.parametrize(
+    'rev',
+    (
+        'v0.12.4',
+        'b27f281',
+        'b27f281eb9398fc8504415d7fbdabf119ea8c5e1',
+        '19.10b0',
+        '4.3.21-2',
+    ),
+)
+def test_warn_mutable_rev_ok(caplog, rev):
+    config_obj = {
+        'repo': 'https://gitlab.com/pycqa/flake8',
+        'rev': rev,
+        'hooks': [{'id': 'flake8'}],
+    }
+    cfgv.validate(config_obj, CONFIG_REPO_DICT)
+
+    assert caplog.record_tuples == []
+
+
+@pytest.mark.parametrize(
+    'rev',
+    (
+        '',
+        'HEAD',
+        'stable',
+        'master',
+        'some_branch_name',
+    ),
+)
+def test_warn_mutable_rev_invalid(caplog, rev):
+    config_obj = {
+        'repo': 'https://gitlab.com/pycqa/flake8',
+        'rev': rev,
+        'hooks': [{'id': 'flake8'}],
+    }
+    cfgv.validate(config_obj, CONFIG_REPO_DICT)
+
+    assert caplog.record_tuples == [
+        (
+            'pre_commit',
+            logging.WARNING,
+            "The 'rev' field of repo 'https://gitlab.com/pycqa/flake8' "
+            'appears to be a mutable reference (moving tag / branch).  '
+            'Mutable references are never updated after first install and are '
+            'not supported.  '
+            'See https://pre-commit.com/#using-the-latest-version-for-a-repository '  # noqa: E501
+            'for more details.',
+        ),
+    ]
+
+
+def test_warn_mutable_rev_conditional():
+    config_obj = {
+        'repo': 'meta',
+        'rev': '3.7.7',
+        'hooks': [{'id': 'flake8'}],
+    }
+
+    with pytest.raises(cfgv.ValidationError):
+        cfgv.validate(config_obj, CONFIG_REPO_DICT)
+
+
 def test_validate_optional_sensible_regex(caplog):
     config_obj = {
         'id': 'flake8',