Repository: pre-commit/pre-commit
Pull request #2861 (2023-04-29 19:27:59)

perform autoupdate without Store contention


Patch:
diff --git a/pre_commit/commands/autoupdate.py b/pre_commit/commands/autoupdate.py
index 347599f..71e5c99 100644
--- a/pre_commit/commands/autoupdate.py
+++ b/pre_commit/commands/autoupdate.py
@@ -16,7 +16,6 @@ from pre_commit.clientlib import load_manifest
 from pre_commit.clientlib import LOCAL
 from pre_commit.clientlib import META
 from pre_commit.commands.migrate_config import migrate_config
-from pre_commit.store import Store
 from pre_commit.util import CalledProcessError
 from pre_commit.util import cmd_output
 from pre_commit.util import cmd_output_b
@@ -27,11 +26,12 @@ from pre_commit.yaml import yaml_load
 class RevInfo(NamedTuple):
     repo: str
     rev: str
-    frozen: str | None
+    frozen: str | None = None
+    hook_ids: frozenset[str] = frozenset()
 
     @classmethod
     def from_config(cls, config: dict[str, Any]) -> RevInfo:
-        return cls(config['repo'], config['rev'], None)
+        return cls(config['repo'], config['rev'])
 
     def update(self, tags_only: bool, freeze: bool) -> RevInfo:
         with tempfile.TemporaryDirectory() as tmp:
@@ -63,7 +63,19 @@ class RevInfo(NamedTuple):
                 exact = cmd_output(*_git, 'rev-parse', rev)[1].strip()
                 if exact != rev:
                     rev, frozen = exact, rev
-        return self._replace(rev=rev, frozen=frozen)
+
+            try:
+                cmd_output(*_git, 'checkout', rev, '--', C.MANIFEST_FILE)
+            except CalledProcessError:
+                pass  # this will be caught by manifest validating code
+            try:
+                manifest = load_manifest(os.path.join(tmp, C.MANIFEST_FILE))
+            except InvalidManifestError as e:
+                raise RepositoryCannotBeUpdatedError(str(e))
+            else:
+                hook_ids = frozenset(hook['id'] for hook in manifest)
+
+        return self._replace(rev=rev, frozen=frozen, hook_ids=hook_ids)
 
 
 class RepositoryCannotBeUpdatedError(RuntimeError):
@@ -73,17 +85,10 @@ class RepositoryCannotBeUpdatedError(RuntimeError):
 def _check_hooks_still_exist_at_rev(
         repo_config: dict[str, Any],
         info: RevInfo,
-        store: Store,
 ) -> None:
-    try:
-        path = store.clone(repo_config['repo'], info.rev)
-        manifest = load_manifest(os.path.join(path, C.MANIFEST_FILE))
-    except InvalidManifestError as e:
-        raise RepositoryCannotBeUpdatedError(str(e))
-
     # See if any of our hooks were deleted with the new commits
     hooks = {hook['id'] for hook in repo_config['hooks']}
-    hooks_missing = hooks - {hook['id'] for hook in manifest}
+    hooks_missing = hooks - info.hook_ids
     if hooks_missing:
         raise RepositoryCannotBeUpdatedError(
             f'Cannot update because the update target is missing these '
@@ -139,7 +144,6 @@ def _write_new_config(path: str, rev_infos: list[RevInfo | None]) -> None:
 
 def autoupdate(
         config_file: str,
-        store: Store,
         tags_only: bool,
         freeze: bool,
         repos: Sequence[str] = (),
@@ -161,9 +165,9 @@ def autoupdate(
             continue
 
         output.write(f'Updating {info.repo} ... ')
-        new_info = info.update(tags_only=tags_only, freeze=freeze)
         try:
-            _check_hooks_still_exist_at_rev(repo_config, new_info, store)
+            new_info = info.update(tags_only=tags_only, freeze=freeze)
+            _check_hooks_still_exist_at_rev(repo_config, new_info)
         except RepositoryCannotBeUpdatedError as error:
             output.write_line(error.args[0])
             rev_infos.append(None)
diff --git a/pre_commit/main.py b/pre_commit/main.py
index 9615c5e..402bc2e 100644
--- a/pre_commit/main.py
+++ b/pre_commit/main.py
@@ -368,7 +368,7 @@ def main(argv: Sequence[str] | None = None) -> int:
 
         if args.command == 'autoupdate':
             return autoupdate(
-                args.config, store,
+                args.config,
                 tags_only=not args.bleeding_edge,
                 freeze=args.freeze,
                 repos=args.repos,


Test patch:
diff --git a/tests/commands/autoupdate_test.py b/tests/commands/autoupdate_test.py
index 4bcb5d8..71bd044 100644
--- a/tests/commands/autoupdate_test.py
+++ b/tests/commands/autoupdate_test.py
@@ -67,7 +67,7 @@ def test_rev_info_from_config():
 
 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)
+    info = RevInfo.from_config(config)._replace(hook_ids=frozenset(('foo',)))
     new_info = info.update(tags_only=False, freeze=False)
     assert info == new_info
 
@@ -139,7 +139,7 @@ def test_rev_info_update_does_not_freeze_if_already_sha(out_of_date):
     assert new_info.frozen is None
 
 
-def test_autoupdate_up_to_date_repo(up_to_date, tmpdir, store):
+def test_autoupdate_up_to_date_repo(up_to_date, tmpdir):
     contents = (
         f'repos:\n'
         f'-   repo: {up_to_date}\n'
@@ -150,11 +150,11 @@ def test_autoupdate_up_to_date_repo(up_to_date, tmpdir, store):
     cfg = tmpdir.join(C.CONFIG_FILE)
     cfg.write(contents)
 
-    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 0
+    assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0
     assert cfg.read() == contents
 
 
-def test_autoupdate_old_revision_broken(tempdir_factory, in_tmpdir, store):
+def test_autoupdate_old_revision_broken(tempdir_factory, in_tmpdir):
     """In $FUTURE_VERSION, hooks.yaml will no longer be supported.  This
     asserts that when that day comes, pre-commit will be able to autoupdate
     despite not being able to read hooks.yaml in that repository.
@@ -174,14 +174,14 @@ def test_autoupdate_old_revision_broken(tempdir_factory, in_tmpdir, store):
     write_config('.', config)
     with open(C.CONFIG_FILE) as f:
         before = f.read()
-    assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False) == 0
+    assert autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False) == 0
     with open(C.CONFIG_FILE) as f:
         after = f.read()
     assert before != after
     assert update_rev in after
 
 
-def test_autoupdate_out_of_date_repo(out_of_date, tmpdir, store):
+def test_autoupdate_out_of_date_repo(out_of_date, tmpdir):
     fmt = (
         'repos:\n'
         '-   repo: {}\n'
@@ -192,24 +192,24 @@ def test_autoupdate_out_of_date_repo(out_of_date, tmpdir, store):
     cfg = tmpdir.join(C.CONFIG_FILE)
     cfg.write(fmt.format(out_of_date.path, out_of_date.original_rev))
 
-    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 0
+    assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0
     assert cfg.read() == fmt.format(out_of_date.path, out_of_date.head_rev)
 
 
-def test_autoupdate_with_core_useBuiltinFSMonitor(out_of_date, tmpdir, store):
+def test_autoupdate_with_core_useBuiltinFSMonitor(out_of_date, tmpdir):
     # force the setting on "globally" for git
     home = tmpdir.join('fakehome').ensure_dir()
     home.join('.gitconfig').write('[core]\nuseBuiltinFSMonitor = true\n')
     with envcontext.envcontext((('HOME', str(home)),)):
-        test_autoupdate_out_of_date_repo(out_of_date, tmpdir, store)
+        test_autoupdate_out_of_date_repo(out_of_date, tmpdir)
 
 
-def test_autoupdate_pure_yaml(out_of_date, tmpdir, store):
+def test_autoupdate_pure_yaml(out_of_date, tmpdir):
     with mock.patch.object(yaml, 'Dumper', yaml.yaml.SafeDumper):
-        test_autoupdate_out_of_date_repo(out_of_date, tmpdir, store)
+        test_autoupdate_out_of_date_repo(out_of_date, tmpdir)
 
 
-def test_autoupdate_only_one_to_update(up_to_date, out_of_date, tmpdir, store):
+def test_autoupdate_only_one_to_update(up_to_date, out_of_date, tmpdir):
     fmt = (
         'repos:\n'
         '-   repo: {}\n'
@@ -228,7 +228,7 @@ def test_autoupdate_only_one_to_update(up_to_date, out_of_date, tmpdir, store):
     )
     cfg.write(before)
 
-    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 0
+    assert autoupdate(str(cfg), 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,
@@ -236,7 +236,7 @@ def test_autoupdate_only_one_to_update(up_to_date, out_of_date, tmpdir, store):
 
 
 def test_autoupdate_out_of_date_repo_with_correct_repo_name(
-        out_of_date, in_tmpdir, store,
+        out_of_date, in_tmpdir,
 ):
     stale_config = make_config_from_repo(
         out_of_date.path, rev=out_of_date.original_rev, check=False,
@@ -249,7 +249,7 @@ def test_autoupdate_out_of_date_repo_with_correct_repo_name(
         before = f.read()
     repo_name = f'file://{out_of_date.path}'
     ret = autoupdate(
-        C.CONFIG_FILE, store, freeze=False, tags_only=False,
+        C.CONFIG_FILE, freeze=False, tags_only=False,
         repos=(repo_name,),
     )
     with open(C.CONFIG_FILE) as f:
@@ -261,7 +261,7 @@ def test_autoupdate_out_of_date_repo_with_correct_repo_name(
 
 
 def test_autoupdate_out_of_date_repo_with_wrong_repo_name(
-        out_of_date, in_tmpdir, store,
+        out_of_date, in_tmpdir,
 ):
     config = make_config_from_repo(
         out_of_date.path, rev=out_of_date.original_rev, check=False,
@@ -272,7 +272,7 @@ def test_autoupdate_out_of_date_repo_with_wrong_repo_name(
         before = f.read()
     # It will not update it, because the name doesn't match
     ret = autoupdate(
-        C.CONFIG_FILE, store, freeze=False, tags_only=False,
+        C.CONFIG_FILE, freeze=False, tags_only=False,
         repos=('dne',),
     )
     with open(C.CONFIG_FILE) as f:
@@ -281,7 +281,7 @@ def test_autoupdate_out_of_date_repo_with_wrong_repo_name(
     assert before == after
 
 
-def test_does_not_reformat(tmpdir, out_of_date, store):
+def test_does_not_reformat(tmpdir, out_of_date):
     fmt = (
         'repos:\n'
         '-   repo: {}\n'
@@ -294,12 +294,12 @@ def test_does_not_reformat(tmpdir, out_of_date, store):
     cfg = tmpdir.join(C.CONFIG_FILE)
     cfg.write(fmt.format(out_of_date.path, out_of_date.original_rev))
 
-    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 0
+    assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0
     expected = fmt.format(out_of_date.path, out_of_date.head_rev)
     assert cfg.read() == expected
 
 
-def test_does_not_change_mixed_endlines_read(up_to_date, tmpdir, store):
+def test_does_not_change_mixed_endlines_read(up_to_date, tmpdir):
     fmt = (
         'repos:\n'
         '-   repo: {}\n'
@@ -314,11 +314,11 @@ def test_does_not_change_mixed_endlines_read(up_to_date, tmpdir, store):
     expected = fmt.format(up_to_date, git.head_rev(up_to_date)).encode()
     cfg.write_binary(expected)
 
-    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 0
+    assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0
     assert cfg.read_binary() == expected
 
 
-def test_does_not_change_mixed_endlines_write(tmpdir, out_of_date, store):
+def test_does_not_change_mixed_endlines_write(tmpdir, out_of_date):
     fmt = (
         'repos:\n'
         '-   repo: {}\n'
@@ -333,12 +333,12 @@ def test_does_not_change_mixed_endlines_write(tmpdir, out_of_date, store):
         fmt.format(out_of_date.path, out_of_date.original_rev).encode(),
     )
 
-    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 0
+    assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0
     expected = fmt.format(out_of_date.path, out_of_date.head_rev).encode()
     assert cfg.read_binary() == expected
 
 
-def test_loses_formatting_when_not_detectable(out_of_date, store, tmpdir):
+def test_loses_formatting_when_not_detectable(out_of_date, tmpdir):
     """A best-effort attempt is made at updating rev without rewriting
     formatting.  When the original formatting cannot be detected, this
     is abandoned.
@@ -359,7 +359,7 @@ def test_loses_formatting_when_not_detectable(out_of_date, store, tmpdir):
     cfg = tmpdir.join(C.CONFIG_FILE)
     cfg.write(config)
 
-    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 0
+    assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0
     expected = (
         f'repos:\n'
         f'-   repo: {out_of_date.path}\n'
@@ -370,43 +370,43 @@ def test_loses_formatting_when_not_detectable(out_of_date, store, tmpdir):
     assert cfg.read() == expected
 
 
-def test_autoupdate_tagged_repo(tagged, in_tmpdir, store):
+def test_autoupdate_tagged_repo(tagged, in_tmpdir):
     config = make_config_from_repo(tagged.path, rev=tagged.original_rev)
     write_config('.', config)
 
-    assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False) == 0
+    assert autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False) == 0
     with open(C.CONFIG_FILE) as f:
         assert 'v1.2.3' in f.read()
 
 
-def test_autoupdate_freeze(tagged, in_tmpdir, store):
+def test_autoupdate_freeze(tagged, in_tmpdir):
     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
+    assert autoupdate(C.CONFIG_FILE, freeze=True, tags_only=False) == 0
     with open(C.CONFIG_FILE) as f:
         expected = f'rev: {tagged.head_rev}  # frozen: v1.2.3'
         assert expected in f.read()
 
     # if we un-freeze it should remove the frozen comment
-    assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False) == 0
+    assert autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False) == 0
     with open(C.CONFIG_FILE) as f:
         assert 'rev: v1.2.3\n' in f.read()
 
 
-def test_autoupdate_tags_only(tagged, in_tmpdir, store):
+def test_autoupdate_tags_only(tagged, in_tmpdir):
     # 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)
 
-    assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=True) == 0
+    assert autoupdate(C.CONFIG_FILE, 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, in_tmpdir, store):
+def test_autoupdate_latest_no_config(out_of_date, in_tmpdir):
     config = make_config_from_repo(
         out_of_date.path, rev=out_of_date.original_rev,
     )
@@ -415,12 +415,12 @@ def test_autoupdate_latest_no_config(out_of_date, in_tmpdir, store):
     cmd_output('git', 'rm', '-r', ':/', cwd=out_of_date.path)
     git_commit(cwd=out_of_date.path)
 
-    assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False) == 1
+    assert autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False) == 1
     with open(C.CONFIG_FILE) as f:
         assert out_of_date.original_rev in f.read()
 
 
-def test_hook_disppearing_repo_raises(hook_disappearing, store):
+def test_hook_disppearing_repo_raises(hook_disappearing):
     config = make_config_from_repo(
         hook_disappearing.path,
         rev=hook_disappearing.original_rev,
@@ -428,10 +428,10 @@ def test_hook_disppearing_repo_raises(hook_disappearing, store):
     )
     info = RevInfo.from_config(config).update(tags_only=False, freeze=False)
     with pytest.raises(RepositoryCannotBeUpdatedError):
-        _check_hooks_still_exist_at_rev(config, info, store)
+        _check_hooks_still_exist_at_rev(config, info)
 
 
-def test_autoupdate_hook_disappearing_repo(hook_disappearing, tmpdir, store):
+def test_autoupdate_hook_disappearing_repo(hook_disappearing, tmpdir):
     contents = (
         f'repos:\n'
         f'-   repo: {hook_disappearing.path}\n'
@@ -442,21 +442,21 @@ def test_autoupdate_hook_disappearing_repo(hook_disappearing, tmpdir, store):
     cfg = tmpdir.join(C.CONFIG_FILE)
     cfg.write(contents)
 
-    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 1
+    assert autoupdate(str(cfg), freeze=False, tags_only=False) == 1
     assert cfg.read() == contents
 
 
-def test_autoupdate_local_hooks(in_git_dir, store):
+def test_autoupdate_local_hooks(in_git_dir):
     config = sample_local_config()
     add_config_to_repo('.', config)
-    assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False) == 0
+    assert autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False) == 0
     new_config_written = read_config('.')
     assert len(new_config_written['repos']) == 1
     assert new_config_written['repos'][0] == config
 
 
 def test_autoupdate_local_hooks_with_out_of_date_repo(
-        out_of_date, in_tmpdir, store,
+        out_of_date, in_tmpdir,
 ):
     stale_config = make_config_from_repo(
         out_of_date.path, rev=out_of_date.original_rev, check=False,
@@ -464,13 +464,13 @@ def test_autoupdate_local_hooks_with_out_of_date_repo(
     local_config = sample_local_config()
     config = {'repos': [local_config, stale_config]}
     write_config('.', config)
-    assert autoupdate(C.CONFIG_FILE, store, freeze=False, tags_only=False) == 0
+    assert autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False) == 0
     new_config_written = read_config('.')
     assert len(new_config_written['repos']) == 2
     assert new_config_written['repos'][0] == local_config
 
 
-def test_autoupdate_meta_hooks(tmpdir, store):
+def test_autoupdate_meta_hooks(tmpdir):
     cfg = tmpdir.join(C.CONFIG_FILE)
     cfg.write(
         'repos:\n'
@@ -478,7 +478,7 @@ def test_autoupdate_meta_hooks(tmpdir, store):
         '    hooks:\n'
         '    -   id: check-useless-excludes\n',
     )
-    assert autoupdate(str(cfg), store, freeze=False, tags_only=True) == 0
+    assert autoupdate(str(cfg), freeze=False, tags_only=True) == 0
     assert cfg.read() == (
         'repos:\n'
         '-   repo: meta\n'
@@ -487,7 +487,7 @@ def test_autoupdate_meta_hooks(tmpdir, store):
     )
 
 
-def test_updates_old_format_to_new_format(tmpdir, capsys, store):
+def test_updates_old_format_to_new_format(tmpdir, capsys):
     cfg = tmpdir.join(C.CONFIG_FILE)
     cfg.write(
         '-   repo: local\n'
@@ -497,7 +497,7 @@ def test_updates_old_format_to_new_format(tmpdir, capsys, store):
         '        entry: ./bin/foo.sh\n'
         '        language: script\n',
     )
-    assert autoupdate(str(cfg), store, freeze=False, tags_only=True) == 0
+    assert autoupdate(str(cfg), freeze=False, tags_only=True) == 0
     contents = cfg.read()
     assert contents == (
         'repos:\n'
@@ -512,7 +512,7 @@ def test_updates_old_format_to_new_format(tmpdir, capsys, store):
     assert out == 'Configuration has been migrated.\n'
 
 
-def test_maintains_rev_quoting_style(tmpdir, out_of_date, store):
+def test_maintains_rev_quoting_style(tmpdir, out_of_date):
     fmt = (
         'repos:\n'
         '-   repo: {path}\n'
@@ -527,6 +527,6 @@ def test_maintains_rev_quoting_style(tmpdir, out_of_date, store):
     cfg = tmpdir.join(C.CONFIG_FILE)
     cfg.write(fmt.format(path=out_of_date.path, rev=out_of_date.original_rev))
 
-    assert autoupdate(str(cfg), store, freeze=False, tags_only=False) == 0
+    assert autoupdate(str(cfg), freeze=False, tags_only=False) == 0
     expected = fmt.format(path=out_of_date.path, rev=out_of_date.head_rev)
     assert cfg.read() == expected
diff --git a/tests/commands/gc_test.py b/tests/commands/gc_test.py
index c128e93..95113ed 100644
--- a/tests/commands/gc_test.py
+++ b/tests/commands/gc_test.py
@@ -43,8 +43,9 @@ def test_gc(tempdir_factory, store, in_git_dir, cap_out):
     store.mark_config_used(C.CONFIG_FILE)
 
     # 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, freeze=False, tags_only=False)
+    assert not install_hooks(C.CONFIG_FILE, store)
+    assert not autoupdate(C.CONFIG_FILE, freeze=False, tags_only=False)
+    assert not install_hooks(C.CONFIG_FILE, store)
 
     assert _config_count(store) == 1
     assert _repo_count(store) == 2