Repository: pre-commit/pre-commit

Issue:
Integrity check
Hello,

First of all, thank you for this project. I really appreciate using it.

This issue is a feature request to add an integrity check for security reasons. In fact, even if there is the possibility to specify a Git tag to download a particular version of a pre-commit hook, it does not guaranty that the tag will point to the same commit between two downloads. Knowing that a Git hook is an executable, and that a Git tag can be modified to point to another commit, it becomes impossible to trust the hooks downloaded by pre-commit without reviewing them each time they are installed, which is a security risk. An attacker getting write access to a Git repository containing pre-commit hooks could add a harmful payload to them and make the latest tag point to this new malicious version for example.

To remediate this situation, I would suggest to add the commit ID as a new `repos` property to the configuration file, at the same imbrication-level as `rev`. This way, pre-commit will be able to verify that the Git tag specified by `rev` points to the expected Git commit.

I look forward to getting your feedback on it.

Cheers.


Discussion:
rev doesn't have to point to a tag, you can already use a revision for that.
there was consideration to make a "lock" file at some point but there has been no proposal or spec put forth.  I can't find the original issue from a quick search -- I don't want to put it in the base configuration as it is highly prone to merge conflicts 
> rev doesn't have to point to a tag, you can already use a revision for that.

Great! Because all the pre-commit config files I have read so far used Git tags, I thought it was the only way to specify a version. It makes sense to me now that `rev` has been chosen for the name of this parameter, and not `tag`.

Is it also possible to specify a branch name? I think it would be worth adding a few words in the documentation to indicate what values this parameter can take, and why not a small paragraph about the security implications.
It [used to be called `sha`](https://pre-commit.com/#pre-commit-configyaml---repos) but humans don't like reading shas and very much can't reason about them -- it can be a branch but the docs [discourage this](https://pre-commit.com/#using-the-latest-version-for-a-repository).  I do think you're blowing security a bit out of proportion here, it's not any different than cloning a git repository from the internet or much different installing a package from say npm -- precisely why this feature hasn't been prioritized.

The value is meant to be immutable, but the framework currently doesn't care whether it actually is (you can put branches there or mutable tags all the same)
> it can be a branch but the docs discourage this.

I completely agree that it should be discouraged. My point was that all the possible values should be clearly documented, preferably directly in the table where the different parameters are listed.

> I do think you're blowing security a bit out of proportion here, it's not any different than cloning a git repository from the internet or much different installing a package from say npm -- precisely why this feature hasn't been prioritized.

Well, I agree that it's not a good thing to blindly use source code or install npm packages, specially knowing that those packages are not signed. However, it's not because people do it that it should be encouraged. Security issues occur regularly because of [compromised dependencies](https://snyk.io/blog/yet-another-malicious-package-found-in-npm-targeting-cryptocurrency-wallets/).

The best way to know if a software is safe to be used is to read its source code. However, it is extremely time-consuming and not in everyone's reach. At some point, we need to trust someone else, and it makes generally sense to trust the developers of the project, hence the signing mechanism. That's why the only two ways I see to get a good balance between convenience and safety are either to use immutable references (commit IDs) after having read the source code of the pre-commit hooks, or to ask pre-commit to verify the signatures of the remote references (Git tags, commits, or branches).

I suppose the locking mechanism you mentioned is the way-to-go, in particular because most people don't sign their work. If you agree with me, I would be happy to work on the implementation as soon as I get a bit more of free time.
I'm not even sure a locking mechanism is necessary now that I think about it -- using `rev: <40-char sha1>` is essentially your lock.
Maybe a convenience command to turn a config into a locked config?
> I'm not even sure a locking mechanism is necessary now that I think about it -- using `rev: <40-char sha1>` is essentially your lock.

It's what I thought initially but using a locking mechanism would be a way to detect drifts if your targeted Git tag has been changed to point to another commit since the last time you used `pre-commit install`.
> > I'm not even sure a locking mechanism is necessary now that I think about it -- using `rev: <40-char sha1>` is essentially your lock.
> 
> It's what I thought initially but using a locking mechanism would be a way to detect drifts if your targeted Git tag has been changed to point to another commit since the last time you used `pre-commit install`.

🤷‍♂ at that point who cares though -- you're just installing from a `sha`
> :man_shrugging: at that point who cares though -- you're just installing from a `sha`

I meant in the case where you use a Git tag in `.pre-commit-config.yaml`, and not a commit ID directly.
Right so it would convert that to a sha1
Using a sha for the `rev` does work, the problem is that it breaks the autoupdate functionality.

I would propose supporting both `rev` and `sha` at the same time.  Or instead of `sha` maybe `lock`?

`pre-commit install-hooks` should verify that the two point to the same place.
`pre-commit autoupdate` should update based on the `rev` and place the new `sha` there as well at the time of the update.  In the future if these two deviate, it'll be detected before the malicious code is ran.

If the user is using `autoupdate`, then they'll never have to touch the sha themselves.

### Example

```yaml
repos:
-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v2.4.0
    sha: 0161422b4e09b47536ea13f49e786eb3616fe0d7
    hooks:
    -   id: trailing-whitespace
```

Nearly every other package manager or dependency manager is now supporting checksums in the form of lock files.  This project should do the same.
autoupdate works just fine, have you tried `--bleeding-edge`?
(the proposal also doesn't address my concern above: this leads to lots of difficult-to-resolve merge conflicts -- if people are just regenerating their lock files blindly they aren't actually more secure than before)
@asottile running autoupdate replaces the sha with a tag.  I'm asking that it replace the old tag with a new one and the old sha with a new one.

```
$ pre-commit autoupdate
Updating https://github.com/pre-commit/pre-commit-hooks...updating 0161422b4e09b47536ea13f49e786eb3616fe0d7 -> v2.4.0.
Updating https://github.com/psf/black...updating 6bedb5c58a7d8c25aa9509f8217bc24e9797e90d -> 19.10b0.
```
### before

```yaml
repos:
-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: 0161422b4e09b47536ea13f49e786eb3616fe0d7  # v2.4.0
    hooks:
    -   id: trailing-whitespace
    -   id: end-of-file-fixer
    -   id: check-yaml
    -   id: check-added-large-files
-   repo: https://github.com/psf/black
    rev: 6bedb5c58a7d8c25aa9509f8217bc24e9797e90d  # 19.10b0
    hooks:
    -   id: black
```

### after

```yaml
repos:
-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v2.4.0  # v2.4.0
    hooks:
    -   id: trailing-whitespace
    -   id: end-of-file-fixer
    -   id: check-yaml
    -   id: check-added-large-files
-   repo: https://github.com/psf/black
    rev: 19.10b0  # 19.10b0
    hooks:
    -   id: black
```
I don't like repeating myself, but have you tried `--bleeding-edge`? you seem to be talking past me which I do not appreciate
I have looked at `--bleeding-edge`.  This is not the behavior I would want.  I'd do want to use a tag, for instance `v2.4.0`.  I also want to be guaranteed that the code doesn't change out from under me if I clone and initialize the repo yesterday vs today.
As for the merge conflict issue, I don't see how having a sha along for the ride changes anything.  You'd have a merge conflict on the `sha` in any scenario where you'd also have a a merge conflict on a `rev`
the ergonomics of a merge conflict are pretty important to me and I've seen them sour relationships with tools in the past, consider trying to resolve the following conflicts:

```diff
 -   repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v2.3.0
+    rev: v2.4.0
     hooks:
```

```diff
 -   repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: 033b22aac37efd4e967f9896e2de0fb16a40ff9b
+    rev: a67a754d35ceac1407ab177d21995f4b82f97b28
     hooks:
```

If you're paranoid (it seems you are) I'd suggest converting the shas manually until a reasonable proposal / implementation exists (as documented above)
@asottile I never suggested using a sha in place of a tag.  Please re-read my posts.  I even included examples.  I'm suggesting to use them together at the same time.

This should not be a problem.

This config file won't be updated as often as other code.  When it is modified, the majority of the time there will not be a merge conflict.  This is because typically multiple people will not edit it at the same time.  If they happen to, then you'd have to deal with it like any other merge conflict and not mix and match.  Either take the `rev` and `sha` from one commit or the other.  Just don't mix and match.  This is what the diff would look like.

> ```diff
>  -   repo: https://github.com/pre-commit/pre-commit-hooks
> -    rev: v2.3.0
> -    rev: 033b22aac37efd4e967f9896e2de0fb16a40ff9b
> +    rev: v2.4.0
> +    rev: a67a754d35ceac1407ab177d21995f4b82f97b28
>      hooks:
> ```

As an example, look at either...

- 4M [package-lock.json](https://github.com/search?utf8=%E2%9C%93&q=filename%3Apackage-lock.json&type=Code&ref=advsearch&l=&l=) files.
- 72k [Pipfile.lock](https://github.com/search?l=&q=filename%3APipfile.lock&type=Code) files

... and find in the history when there has been a merge conflict involving those files.
that's a breaking config change, it would need a new field and I don't think there's any advantage over just putting a sha1 in the rev field
If you use a sha, then you're not using a tag with all of its advantages.

- human readable semantic versions (for humans dealing with merge requests)
- computer readable semantic versions (for `autoupdate` to work)

Likewise, if you use a tag, then you're not using a sha with all of its advantages.

- integrity

So, the advantage of having both as opposed to _"just putting a sha1 in the rev field"_ is that you don't give up other advantages.
sha1 in the rev field plus a comment is equivalent without needing special fields - it wouldn't be too difficult to make autoupdate spit out that format

I just don't see the value in having two fields given the tag would be purely informational and the `rev` field already supports both (and the `sha` field is misnamed, deprecated, and forbidden when `rev` is present currently)
@asottile so having `rev` with a sha value and a tag comment?  That is what I'm currently doing but it's a manual process.  See [previous comment](https://github.com/pre-commit/pre-commit/issues/1068#issuecomment-561280026) showing how `autoupdate` currently handles this.

I always feel dirty putting data into comments.  If the tool were able to update the comment too, that'd be better.
yes, if that works for you we can make it happen

Resolving patch:
diff --git a/pre_commit/clientlib.py b/pre_commit/clientlib.py
--- a/pre_commit/clientlib.py
+++ b/pre_commit/clientlib.py
@@ -133,8 +133,7 @@ def apply_default(self, dct):
         if 'sha' in dct:
             dct['rev'] = dct.pop('sha')
 
-    def remove_default(self, dct):
-        pass
+    remove_default = cfgv.Required.remove_default
 
 
 def _entry(modname):
diff --git a/pre_commit/commands/autoupdate.py b/pre_commit/commands/autoupdate.py
--- a/pre_commit/commands/autoupdate.py
+++ b/pre_commit/commands/autoupdate.py
@@ -1,18 +1,17 @@
 from __future__ import print_function
 from __future__ import unicode_literals
 
+import collections
 import os.path
 import re
 
 import six
 from aspy.yaml import ordered_dump
 from aspy.yaml import ordered_load
-from cfgv import remove_defaults
 
 import pre_commit.constants as C
 from pre_commit import git
 from pre_commit import output
-from pre_commit.clientlib import CONFIG_SCHEMA
 from pre_commit.clientlib import InvalidManifestError
 from pre_commit.clientlib import load_config
 from pre_commit.clientlib import load_manifest
@@ -25,39 +24,44 @@
 from pre_commit.util import tmpdir
 
 
-class RepositoryCannotBeUpdatedError(RuntimeError):
-    pass
-
+class RevInfo(collections.namedtuple('RevInfo', ('repo', 'rev', 'frozen'))):
+    __slots__ = ()
 
-def _update_repo(repo_config, store, tags_only):
-    """Updates a repository to the tip of `master`.  If the repository cannot
-    be updated because a hook that is configured does not exist in `master`,
-    this raises a RepositoryCannotBeUpdatedError
+    @classmethod
+    def from_config(cls, config):
+        return cls(config['repo'], config['rev'], None)
 
-    Args:
-        repo_config - A config for a repository
-    """
-    with tmpdir() as repo_path:
-        git.init_repo(repo_path, repo_config['repo'])
-        cmd_output_b('git', 'fetch', 'origin', 'HEAD', '--tags', cwd=repo_path)
-
-        tag_cmd = ('git', 'describe', 'FETCH_HEAD', '--tags')
+    def update(self, tags_only, freeze):
         if tags_only:
-            tag_cmd += ('--abbrev=0',)
+            tag_cmd = ('git', 'describe', 'FETCH_HEAD', '--tags', '--abbrev=0')
         else:
-            tag_cmd += ('--exact',)
-        try:
-            rev = cmd_output(*tag_cmd, cwd=repo_path)[1].strip()
-        except CalledProcessError:
-            tag_cmd = ('git', 'rev-parse', 'FETCH_HEAD')
-            rev = cmd_output(*tag_cmd, cwd=repo_path)[1].strip()
+            tag_cmd = ('git', 'describe', 'FETCH_HEAD', '--tags', '--exact')
+
+        with tmpdir() as tmp:
+            git.init_repo(tmp, self.repo)
+            cmd_output_b('git', 'fetch', 'origin', 'HEAD', '--tags', cwd=tmp)
+
+            try:
+                rev = cmd_output(*tag_cmd, cwd=tmp)[1].strip()
+            except CalledProcessError:
+                cmd = ('git', 'rev-parse', 'FETCH_HEAD')
+                rev = cmd_output(*cmd, cwd=tmp)[1].strip()
+
+            frozen = None
+            if freeze:
+                exact = cmd_output('git', 'rev-parse', rev, cwd=tmp)[1].strip()
+                if exact != rev:
+                    rev, frozen = exact, rev
+        return self._replace(rev=rev, frozen=frozen)
+
+
+class RepositoryCannotBeUpdatedError(RuntimeError):
+    pass
 
-    # Don't bother trying to update if our rev is the same
-    if rev == repo_config['rev']:
-        return repo_config
 
+def _check_hooks_still_exist_at_rev(repo_config, info, store):
     try:
-        path = store.clone(repo_config['repo'], rev)
+        path = store.clone(repo_config['repo'], info.rev)
         manifest = load_manifest(os.path.join(path, C.MANIFEST_FILE))
     except InvalidManifestError as e:
         raise RepositoryCannotBeUpdatedError(six.text_type(e))
@@ -71,94 +75,91 @@ def _update_repo(repo_config, store, tags_only):
             '{}'.format(', '.join(sorted(hooks_missing))),
         )
 
-    # Construct a new config with the head rev
-    new_config = repo_config.copy()
-    new_config['rev'] = rev
-    return new_config
-
 
-REV_LINE_RE = re.compile(r'^(\s+)rev:(\s*)([^\s#]+)(.*)$', re.DOTALL)
-REV_LINE_FMT = '{}rev:{}{}{}'
+REV_LINE_RE = re.compile(r'^(\s+)rev:(\s*)([^\s#]+)(.*)(\r?\n)$', re.DOTALL)
+REV_LINE_FMT = '{}rev:{}{}{}{}'
 
 
-def _write_new_config_file(path, output):
+def _original_lines(path, rev_infos, retry=False):
+    """detect `rev:` lines or reformat the file"""
     with open(path) as f:
-        original_contents = f.read()
-    output = remove_defaults(output, CONFIG_SCHEMA)
-    new_contents = ordered_dump(output, **C.YAML_DUMP_KWARGS)
-
-    lines = original_contents.splitlines(True)
-    rev_line_indices_reversed = list(
-        reversed([
-            i for i, line in enumerate(lines) if REV_LINE_RE.match(line)
-        ]),
-    )
-
-    for line in new_contents.splitlines(True):
-        if REV_LINE_RE.match(line):
-            # It's possible we didn't identify the rev lines in the original
-            if not rev_line_indices_reversed:
-                break
-            line_index = rev_line_indices_reversed.pop()
-            original_line = lines[line_index]
-            orig_match = REV_LINE_RE.match(original_line)
-            new_match = REV_LINE_RE.match(line)
-            lines[line_index] = REV_LINE_FMT.format(
-                orig_match.group(1), orig_match.group(2),
-                new_match.group(3), orig_match.group(4),
-            )
-
-    # If we failed to intelligently rewrite the rev lines, fall back to the
-    # pretty-formatted yaml output
-    to_write = ''.join(lines)
-    if remove_defaults(ordered_load(to_write), CONFIG_SCHEMA) != output:
-        to_write = new_contents
+        original = f.read()
+
+    lines = original.splitlines(True)
+    idxs = [i for i, line in enumerate(lines) if REV_LINE_RE.match(line)]
+    if len(idxs) == len(rev_infos):
+        return lines, idxs
+    elif retry:
+        raise AssertionError('could not find rev lines')
+    else:
+        with open(path, 'w') as f:
+            f.write(ordered_dump(ordered_load(original), **C.YAML_DUMP_KWARGS))
+        return _original_lines(path, rev_infos, retry=True)
+
+
+def _write_new_config(path, rev_infos):
+    lines, idxs = _original_lines(path, rev_infos)
+
+    for idx, rev_info in zip(idxs, rev_infos):
+        if rev_info is None:
+            continue
+        match = REV_LINE_RE.match(lines[idx])
+        assert match is not None
+        new_rev_s = ordered_dump({'rev': rev_info.rev}, **C.YAML_DUMP_KWARGS)
+        new_rev = new_rev_s.split(':', 1)[1].strip()
+        if rev_info.frozen is not None:
+            comment = '  # {}'.format(rev_info.frozen)
+        else:
+            comment = match.group(4)
+        lines[idx] = REV_LINE_FMT.format(
+            match.group(1), match.group(2), new_rev, comment, match.group(5),
+        )
 
     with open(path, 'w') as f:
-        f.write(to_write)
+        f.write(''.join(lines))
 
 
-def autoupdate(config_file, store, tags_only, repos=()):
+def autoupdate(config_file, store, tags_only, freeze, repos=()):
     """Auto-update the pre-commit config to the latest versions of repos."""
     migrate_config(config_file, quiet=True)
     retv = 0
-    output_repos = []
+    rev_infos = []
     changed = False
 
-    input_config = load_config(config_file)
+    config = load_config(config_file)
+    for repo_config in config['repos']:
+        if repo_config['repo'] in {LOCAL, META}:
+            continue
 
-    for repo_config in input_config['repos']:
-        if (
-            repo_config['repo'] in {LOCAL, META} or
-            # Skip updating any repo_configs that aren't for the specified repo
-            repos and repo_config['repo'] not in repos
-        ):
-            output_repos.append(repo_config)
+        info = RevInfo.from_config(repo_config)
+        if repos and info.repo not in repos:
+            rev_infos.append(None)
             continue
-        output.write('Updating {}...'.format(repo_config['repo']))
+
+        output.write('Updating {}...'.format(info.repo))
+        new_info = info.update(tags_only=tags_only, freeze=freeze)
         try:
-            new_repo_config = _update_repo(repo_config, store, tags_only)
+            _check_hooks_still_exist_at_rev(repo_config, new_info, store)
         except RepositoryCannotBeUpdatedError as error:
             output.write_line(error.args[0])
-            output_repos.append(repo_config)
+            rev_infos.append(None)
             retv = 1
             continue
 
-        if new_repo_config['rev'] != repo_config['rev']:
+        if new_info.rev != info.rev:
             changed = True
-            output.write_line(
-                'updating {} -> {}.'.format(
-                    repo_config['rev'], new_repo_config['rev'],
-                ),
-            )
-            output_repos.append(new_repo_config)
+            if new_info.frozen:
+                updated_to = '{} (frozen)'.format(new_info.frozen)
+            else:
+                updated_to = new_info.rev
+            msg = 'updating {} -> {}.'.format(info.rev, updated_to)
+            output.write_line(msg)
+            rev_infos.append(new_info)
         else:
             output.write_line('already up to date.')
-            output_repos.append(repo_config)
+            rev_infos.append(None)
 
     if changed:
-        output_config = input_config.copy()
-        output_config['repos'] = output_repos
-        _write_new_config_file(config_file, output_config)
+        _write_new_config(config_file, rev_infos)
 
     return retv
diff --git a/pre_commit/main.py b/pre_commit/main.py
--- a/pre_commit/main.py
+++ b/pre_commit/main.py
@@ -175,6 +175,10 @@ def main(argv=None):
             'tagged version (the default behavior).'
         ),
     )
+    autoupdate_parser.add_argument(
+        '--freeze', action='store_true',
+        help='Store "frozen" hashes in `rev` instead of tag names',
+    )
     autoupdate_parser.add_argument(
         '--repo', dest='repos', action='append', metavar='REPO',
         help='Only update this repository -- may be specified multiple times.',
@@ -313,6 +317,7 @@ def main(argv=None):
             return autoupdate(
                 args.config, store,
                 tags_only=not args.bleeding_edge,
+                freeze=args.freeze,
                 repos=args.repos,
             )
         elif args.command == 'clean':


Test patch:
diff --git a/tests/commands/autoupdate_test.py b/tests/commands/autoupdate_test.py
--- a/tests/commands/autoupdate_test.py
+++ b/tests/commands/autoupdate_test.py
@@ -6,9 +6,10 @@
 
 import pre_commit.constants as C
 from pre_commit import git
-from pre_commit.commands.autoupdate import _update_repo
+from pre_commit.commands.autoupdate import _check_hooks_still_exist_at_rev
 from pre_commit.commands.autoupdate import autoupdate
 from pre_commit.commands.autoupdate import RepositoryCannotBeUpdatedError
+from pre_commit.commands.autoupdate import RevInfo
 from pre_commit.util import cmd_output
 from testing.auto_namedtuple import auto_namedtuple
 from testing.fixtures import add_config_to_repo
@@ -22,30 +23,114 @@
 
 
 @pytest.fixture
-def up_to_date_repo(tempdir_factory):
+def up_to_date(tempdir_factory):
     yield make_repo(tempdir_factory, 'python_hooks_repo')
 
 
-def test_up_to_date_repo(up_to_date_repo, store):
-    config = make_config_from_repo(up_to_date_repo)
-    input_rev = config['rev']
-    ret = _update_repo(config, store, tags_only=False)
-    assert ret['rev'] == input_rev
+@pytest.fixture
+def out_of_date(tempdir_factory):
+    path = make_repo(tempdir_factory, 'python_hooks_repo')
+    original_rev = git.head_rev(path)
 
+    git_commit(cwd=path)
+    head_rev = git.head_rev(path)
 
-def test_autoupdate_up_to_date_repo(up_to_date_repo, in_tmpdir, store):
-    # Write out the config
-    config = make_config_from_repo(up_to_date_repo, check=False)
-    write_config('.', config)
+    yield auto_namedtuple(
+        path=path, original_rev=original_rev, head_rev=head_rev,
+    )
 
-    with open(C.CONFIG_FILE) as f:
-        before = f.read()
-    assert '^$' not in before
-    ret = autoupdate(C.CONFIG_FILE, store, tags_only=False)
-    with open(C.CONFIG_FILE) as f:
-        after = f.read()
-    assert ret == 0
-    assert before == after
+
+@pytest.fixture
+def tagged(out_of_date):
+    cmd_output('git', 'tag', 'v1.2.3', cwd=out_of_date.path)
+    yield out_of_date
+
+
+@pytest.fixture
+def hook_disappearing(tempdir_factory):
+    path = make_repo(tempdir_factory, 'python_hooks_repo')
+    original_rev = git.head_rev(path)
+
+    with modify_manifest(path) as manifest:
+        manifest[0]['id'] = 'bar'
+
+    yield auto_namedtuple(path=path, original_rev=original_rev)
+
+
+def test_rev_info_from_config():
+    info = RevInfo.from_config({'repo': 'repo/path', 'rev': 'v1.2.3'})
+    assert info == RevInfo('repo/path', 'v1.2.3', None)
+
+
+def test_rev_info_update_up_to_date_repo(up_to_date):
+    config = make_config_from_repo(up_to_date)
+    info = RevInfo.from_config(config)
+    new_info = info.update(tags_only=False, freeze=False)
+    assert info == new_info
+
+
+def test_rev_info_update_out_of_date_repo(out_of_date):
+    config = make_config_from_repo(
+        out_of_date.path, rev=out_of_date.original_rev,
+    )
+    info = RevInfo.from_config(config)
+    new_info = info.update(tags_only=False, freeze=False)
+    assert new_info.rev == out_of_date.head_rev
+
+
+def test_rev_info_update_non_master_default_branch(out_of_date):
+    # change the default branch to be not-master
+    cmd_output('git', '-C', out_of_date.path, 'branch', '-m', 'dev')
+    test_rev_info_update_out_of_date_repo(out_of_date)
+
+
+def test_rev_info_update_tags_even_if_not_tags_only(tagged):
+    config = make_config_from_repo(tagged.path, rev=tagged.original_rev)
+    info = RevInfo.from_config(config)
+    new_info = info.update(tags_only=False, freeze=False)
+    assert new_info.rev == 'v1.2.3'
+
+
+def test_rev_info_update_tags_only_does_not_pick_tip(tagged):
+    git_commit(cwd=tagged.path)
+    config = make_config_from_repo(tagged.path, rev=tagged.original_rev)
+    info = RevInfo.from_config(config)
+    new_info = info.update(tags_only=True, freeze=False)
+    assert new_info.rev == 'v1.2.3'
+
+
+def test_rev_info_update_freeze_tag(tagged):
+    git_commit(cwd=tagged.path)
+    config = make_config_from_repo(tagged.path, rev=tagged.original_rev)
+    info = RevInfo.from_config(config)
+    new_info = info.update(tags_only=True, freeze=True)
+    assert new_info.rev == tagged.head_rev
+    assert new_info.frozen == 'v1.2.3'
+
+
+def test_rev_info_update_does_not_freeze_if_already_sha(out_of_date):
+    config = make_config_from_repo(
+        out_of_date.path, rev=out_of_date.original_rev,
+    )
+    info = RevInfo.from_config(config)
+    new_info = info.update(tags_only=True, freeze=True)
+    assert new_info.rev == out_of_date.head_rev
+    assert new_info.frozen is None
+
+
+def test_autoupdate_up_to_date_repo(up_to_date, tmpdir, store):
+    contents = (
+        'repos:\n'
+        '-   repo: {}\n'
+        '    rev: {}\n'
+        '    hooks:\n'
+        '    -   id: foo\n'
+    ).format(up_to_date, git.head_rev(up_to_date))
+    cfg = tmpdir.join(C.CONFIG_FILE)
+    cfg.write(contents)
+
+    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 0
+    assert cfg.read() == contents
 
 
 def test_autoupdate_old_revision_broken(tempdir_factory, in_tmpdir, store):
@@ -68,98 +153,101 @@ def test_autoupdate_old_revision_broken(tempdir_factory, in_tmpdir, store):
     write_config('.', config)
     with open(C.CONFIG_FILE) as f:
         before = f.read()
-    ret = autoupdate(C.CONFIG_FILE, store, tags_only=False)
+    assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False) == 0
     with open(C.CONFIG_FILE) as f:
         after = f.read()
-    assert ret == 0
     assert before != after
     assert update_rev in after
 
 
-@pytest.fixture
-def out_of_date_repo(tempdir_factory):
-    path = make_repo(tempdir_factory, 'python_hooks_repo')
-    original_rev = git.head_rev(path)
-
-    git_commit(cwd=path)
-    head_rev = git.head_rev(path)
-
-    yield auto_namedtuple(
-        path=path, original_rev=original_rev, head_rev=head_rev,
+def test_autoupdate_out_of_date_repo(out_of_date, tmpdir, store):
+    fmt = (
+        'repos:\n'
+        '-   repo: {}\n'
+        '    rev: {}\n'
+        '    hooks:\n'
+        '    -   id: foo\n'
     )
+    cfg = tmpdir.join(C.CONFIG_FILE)
+    cfg.write(fmt.format(out_of_date.path, out_of_date.original_rev))
 
-
-def test_out_of_date_repo(out_of_date_repo, store):
-    config = make_config_from_repo(
-        out_of_date_repo.path, rev=out_of_date_repo.original_rev,
-    )
-    ret = _update_repo(config, store, tags_only=False)
-    assert ret['rev'] != out_of_date_repo.original_rev
-    assert ret['rev'] == out_of_date_repo.head_rev
+    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 0
+    assert cfg.read() == fmt.format(out_of_date.path, out_of_date.head_rev)
 
 
-def test_autoupdate_out_of_date_repo(out_of_date_repo, in_tmpdir, store):
-    # Write out the config
-    config = make_config_from_repo(
-        out_of_date_repo.path, rev=out_of_date_repo.original_rev, check=False,
+def test_autoupdate_only_one_to_update(up_to_date, out_of_date, tmpdir, store):
+    fmt = (
+        'repos:\n'
+        '-   repo: {}\n'
+        '    rev: {}\n'
+        '    hooks:\n'
+        '    -   id: foo\n'
+        '-   repo: {}\n'
+        '    rev: {}\n'
+        '    hooks:\n'
+        '    -   id: foo\n'
     )
-    write_config('.', config)
+    cfg = tmpdir.join(C.CONFIG_FILE)
+    before = fmt.format(
+        up_to_date, git.head_rev(up_to_date),
+        out_of_date.path, out_of_date.original_rev,
+    )
+    cfg.write(before)
 
-    with open(C.CONFIG_FILE) as f:
-        before = f.read()
-    ret = autoupdate(C.CONFIG_FILE, store, tags_only=False)
-    with open(C.CONFIG_FILE) as f:
-        after = f.read()
-    assert ret == 0
-    assert before != after
-    # Make sure we don't add defaults
-    assert 'exclude' not in after
-    assert out_of_date_repo.head_rev in after
+    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 0
+    assert cfg.read() == fmt.format(
+        up_to_date, git.head_rev(up_to_date),
+        out_of_date.path, out_of_date.head_rev,
+    )
 
 
 def test_autoupdate_out_of_date_repo_with_correct_repo_name(
-        out_of_date_repo, in_tmpdir, store,
+        out_of_date, in_tmpdir, store,
 ):
     stale_config = make_config_from_repo(
-        out_of_date_repo.path, rev=out_of_date_repo.original_rev, check=False,
+        out_of_date.path, rev=out_of_date.original_rev, check=False,
     )
     local_config = sample_local_config()
     config = {'repos': [stale_config, local_config]}
-    # Write out the config
     write_config('.', config)
 
     with open(C.CONFIG_FILE) as f:
         before = f.read()
-    repo_name = 'file://{}'.format(out_of_date_repo.path)
-    ret = autoupdate(C.CONFIG_FILE, store, tags_only=False, repos=(repo_name,))
+    repo_name = 'file://{}'.format(out_of_date.path)
+    ret = autoupdate(
+        C.CONFIG_FILE, store, freeze=False, tags_only=False,
+        repos=(repo_name,),
+    )
     with open(C.CONFIG_FILE) as f:
         after = f.read()
     assert ret == 0
     assert before != after
-    assert out_of_date_repo.head_rev in after
+    assert out_of_date.head_rev in after
     assert 'local' in after
 
 
 def test_autoupdate_out_of_date_repo_with_wrong_repo_name(
-        out_of_date_repo, in_tmpdir, store,
+        out_of_date, in_tmpdir, store,
 ):
-    # Write out the config
     config = make_config_from_repo(
-        out_of_date_repo.path, rev=out_of_date_repo.original_rev, check=False,
+        out_of_date.path, rev=out_of_date.original_rev, check=False,
     )
     write_config('.', config)
 
     with open(C.CONFIG_FILE) as f:
         before = f.read()
     # It will not update it, because the name doesn't match
-    ret = autoupdate(C.CONFIG_FILE, store, tags_only=False, repos=('dne',))
+    ret = autoupdate(
+        C.CONFIG_FILE, store, freeze=False, tags_only=False,
+        repos=('dne',),
+    )
     with open(C.CONFIG_FILE) as f:
         after = f.read()
     assert ret == 0
     assert before == after
 
 
-def test_does_not_reformat(in_tmpdir, out_of_date_repo, store):
+def test_does_not_reformat(tmpdir, out_of_date, store):
     fmt = (
         'repos:\n'
         '-   repo: {}\n'
@@ -169,20 +257,15 @@ def test_does_not_reformat(in_tmpdir, out_of_date_repo, store):
         '        # These args are because reasons!\n'
         '        args: [foo, bar, baz]\n'
     )
-    config = fmt.format(out_of_date_repo.path, out_of_date_repo.original_rev)
-    with open(C.CONFIG_FILE, 'w') as f:
-        f.write(config)
+    cfg = tmpdir.join(C.CONFIG_FILE)
+    cfg.write(fmt.format(out_of_date.path, out_of_date.original_rev))
 
-    autoupdate(C.CONFIG_FILE, store, tags_only=False)
-    with open(C.CONFIG_FILE) as f:
-        after = f.read()
-    expected = fmt.format(out_of_date_repo.path, out_of_date_repo.head_rev)
-    assert after == expected
+    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 0
+    expected = fmt.format(out_of_date.path, out_of_date.head_rev)
+    assert cfg.read() == expected
 
 
-def test_loses_formatting_when_not_detectable(
-        out_of_date_repo, store, in_tmpdir,
-):
+def test_loses_formatting_when_not_detectable(out_of_date, store, tmpdir):
     """A best-effort attempt is made at updating rev without rewriting
     formatting.  When the original formatting cannot be detected, this
     is abandoned.
@@ -197,149 +280,119 @@ def test_loses_formatting_when_not_detectable(
         '        ],\n'
         '    }}\n'
         ']\n'.format(
-            pipes.quote(out_of_date_repo.path), out_of_date_repo.original_rev,
+            pipes.quote(out_of_date.path), out_of_date.original_rev,
         )
     )
-    with open(C.CONFIG_FILE, 'w') as f:
-        f.write(config)
+    cfg = tmpdir.join(C.CONFIG_FILE)
+    cfg.write(config)
 
-    autoupdate(C.CONFIG_FILE, store, tags_only=False)
-    with open(C.CONFIG_FILE) as f:
-        after = f.read()
+    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 0
     expected = (
         'repos:\n'
         '-   repo: {}\n'
         '    rev: {}\n'
         '    hooks:\n'
         '    -   id: foo\n'
-    ).format(out_of_date_repo.path, out_of_date_repo.head_rev)
-    assert after == expected
-
-
-@pytest.fixture
-def tagged_repo(out_of_date_repo):
-    cmd_output('git', 'tag', 'v1.2.3', cwd=out_of_date_repo.path)
-    yield out_of_date_repo
+    ).format(out_of_date.path, out_of_date.head_rev)
+    assert cfg.read() == expected
 
 
-def test_autoupdate_tagged_repo(tagged_repo, in_tmpdir, store):
-    config = make_config_from_repo(
-        tagged_repo.path, rev=tagged_repo.original_rev,
-    )
+def test_autoupdate_tagged_repo(tagged, in_tmpdir, store):
+    config = make_config_from_repo(tagged.path, rev=tagged.original_rev)
     write_config('.', config)
 
-    ret = autoupdate(C.CONFIG_FILE, store, tags_only=False)
-    assert ret == 0
+    assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False) == 0
     with open(C.CONFIG_FILE) as f:
         assert 'v1.2.3' in f.read()
 
 
-@pytest.fixture
-def tagged_repo_with_more_commits(tagged_repo):
-    git_commit(cwd=tagged_repo.path)
-    yield tagged_repo
+def test_autoupdate_freeze(tagged, in_tmpdir, store):
+    config = make_config_from_repo(tagged.path, rev=tagged.original_rev)
+    write_config('.', config)
 
+    assert autoupdate(C.CONFIG_FILE, store, freeze=True, tags_only=False) == 0
+    with open(C.CONFIG_FILE) as f:
+        expected = 'rev: {}  # v1.2.3'.format(tagged.head_rev)
+        assert expected in f.read()
 
-def test_autoupdate_tags_only(tagged_repo_with_more_commits, in_tmpdir, store):
-    config = make_config_from_repo(
-        tagged_repo_with_more_commits.path,
-        rev=tagged_repo_with_more_commits.original_rev,
-    )
+
+def test_autoupdate_tags_only(tagged, in_tmpdir, store):
+    # add some commits after the tag
+    git_commit(cwd=tagged.path)
+
+    config = make_config_from_repo(tagged.path, rev=tagged.original_rev)
     write_config('.', config)
 
-    ret = autoupdate(C.CONFIG_FILE, store, tags_only=True)
-    assert ret == 0
+    assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=True) == 0
     with open(C.CONFIG_FILE) as f:
         assert 'v1.2.3' in f.read()
 
 
-def test_autoupdate_latest_no_config(out_of_date_repo, in_tmpdir, store):
+def test_autoupdate_latest_no_config(out_of_date, in_tmpdir, store):
     config = make_config_from_repo(
-        out_of_date_repo.path, rev=out_of_date_repo.original_rev,
+        out_of_date.path, rev=out_of_date.original_rev,
     )
     write_config('.', config)
 
-    cmd_output('git', 'rm', '-r', ':/', cwd=out_of_date_repo.path)
-    git_commit(cwd=out_of_date_repo.path)
+    cmd_output('git', 'rm', '-r', ':/', cwd=out_of_date.path)
+    git_commit(cwd=out_of_date.path)
 
-    ret = autoupdate(C.CONFIG_FILE, store, tags_only=False)
-    assert ret == 1
+    assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False) == 1
     with open(C.CONFIG_FILE) as f:
-        assert out_of_date_repo.original_rev in f.read()
+        assert out_of_date.original_rev in f.read()
 
 
-@pytest.fixture
-def hook_disappearing_repo(tempdir_factory):
-    path = make_repo(tempdir_factory, 'python_hooks_repo')
-    original_rev = git.head_rev(path)
-
-    with modify_manifest(path) as manifest:
-        manifest[0]['id'] = 'bar'
-
-    yield auto_namedtuple(path=path, original_rev=original_rev)
-
-
-def test_hook_disppearing_repo_raises(hook_disappearing_repo, store):
+def test_hook_disppearing_repo_raises(hook_disappearing, store):
     config = make_config_from_repo(
-        hook_disappearing_repo.path,
-        rev=hook_disappearing_repo.original_rev,
+        hook_disappearing.path,
+        rev=hook_disappearing.original_rev,
         hooks=[{'id': 'foo'}],
     )
+    info = RevInfo.from_config(config).update(tags_only=False, freeze=False)
     with pytest.raises(RepositoryCannotBeUpdatedError):
-        _update_repo(config, store, tags_only=False)
-
-
-def test_autoupdate_hook_disappearing_repo(
-        hook_disappearing_repo, in_tmpdir, store,
-):
-    config = make_config_from_repo(
-        hook_disappearing_repo.path,
-        rev=hook_disappearing_repo.original_rev,
-        hooks=[{'id': 'foo'}],
-        check=False,
-    )
-    write_config('.', config)
+        _check_hooks_still_exist_at_rev(config, info, store)
 
-    with open(C.CONFIG_FILE) as f:
-        before = f.read()
-    ret = autoupdate(C.CONFIG_FILE, store, tags_only=False)
-    with open(C.CONFIG_FILE) as f:
-        after = f.read()
-    assert ret == 1
-    assert before == after
 
+def test_autoupdate_hook_disappearing_repo(hook_disappearing, tmpdir, store):
+    contents = (
+        'repos:\n'
+        '-   repo: {}\n'
+        '    rev: {}\n'
+        '    hooks:\n'
+        '    -   id: foo\n'
+    ).format(hook_disappearing.path, hook_disappearing.original_rev)
+    cfg = tmpdir.join(C.CONFIG_FILE)
+    cfg.write(contents)
 
-def test_autoupdate_non_master_default_branch(up_to_date_repo, store):
-    # change the default branch to be not-master
-    cmd_output('git', '-C', up_to_date_repo, 'branch', '-m', 'dev')
-    test_up_to_date_repo(up_to_date_repo, store)
+    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 1
+    assert cfg.read() == contents
 
 
 def test_autoupdate_local_hooks(in_git_dir, store):
     config = sample_local_config()
     add_config_to_repo('.', config)
-    assert autoupdate(C.CONFIG_FILE, store, tags_only=False) == 0
+    assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False) == 0
     new_config_writen = read_config('.')
     assert len(new_config_writen['repos']) == 1
     assert new_config_writen['repos'][0] == config
 
 
 def test_autoupdate_local_hooks_with_out_of_date_repo(
-        out_of_date_repo, in_tmpdir, store,
+        out_of_date, in_tmpdir, store,
 ):
     stale_config = make_config_from_repo(
-        out_of_date_repo.path, rev=out_of_date_repo.original_rev, check=False,
+        out_of_date.path, rev=out_of_date.original_rev, check=False,
     )
     local_config = sample_local_config()
     config = {'repos': [local_config, stale_config]}
     write_config('.', config)
-    assert autoupdate(C.CONFIG_FILE, store, tags_only=False) == 0
+    assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False) == 0
     new_config_writen = read_config('.')
     assert len(new_config_writen['repos']) == 2
     assert new_config_writen['repos'][0] == local_config
 
 
-def test_autoupdate_meta_hooks(tmpdir, capsys, store):
+def test_autoupdate_meta_hooks(tmpdir, store):
     cfg = tmpdir.join(C.CONFIG_FILE)
     cfg.write(
         'repos:\n'
@@ -347,9 +400,7 @@ def test_autoupdate_meta_hooks(tmpdir, capsys, store):
         '    hooks:\n'
         '    -   id: check-useless-excludes\n',
     )
-    with tmpdir.as_cwd():
-        ret = autoupdate(C.CONFIG_FILE, store, tags_only=True)
-    assert ret == 0
+    assert autoupdate(str(cfg), store, freeze=False, tags_only=True) == 0
     assert cfg.read() == (
         'repos:\n'
         '-   repo: meta\n'
@@ -368,9 +419,7 @@ def test_updates_old_format_to_new_format(tmpdir, capsys, store):
         '        entry: ./bin/foo.sh\n'
         '        language: script\n',
     )
-    with tmpdir.as_cwd():
-        ret = autoupdate(C.CONFIG_FILE, store, tags_only=True)
-    assert ret == 0
+    assert autoupdate(str(cfg), store, freeze=False, tags_only=True) == 0
     contents = cfg.read()
     assert contents == (
         'repos:\n'
diff --git a/tests/commands/gc_test.py b/tests/commands/gc_test.py
--- a/tests/commands/gc_test.py
+++ b/tests/commands/gc_test.py
@@ -42,7 +42,7 @@ def test_gc(tempdir_factory, store, in_git_dir, cap_out):
 
     # update will clone both the old and new repo, making the old one gc-able
     install_hooks(C.CONFIG_FILE, store)
-    assert not autoupdate(C.CONFIG_FILE, store, tags_only=False)
+    assert not autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False)
 
     assert _config_count(store) == 1
     assert _repo_count(store) == 2