From 880616df3eb4aa5c8c5749bbf96bcebdfcb68904 Mon Sep 17 00:00:00 2001 From: Jeff Epler Date: Fri, 9 Dec 2022 16:26:55 -0600 Subject: [PATCH] Add `pre-commit autoupdate --semver` This chooses the latest version according to semantic versioning. Prereleases are only chosen if `--semver-prerelease` is also specified. --- pre_commit/commands/autoupdate.py | 117 +++++++++++++----- pre_commit/main.py | 14 +++ setup.cfg | 1 + tests/commands/autoupdate_test.py | 196 +++++++++++++++++++++++++----- tests/commands/gc_test.py | 5 +- 5 files changed, 271 insertions(+), 62 deletions(-) diff --git a/pre_commit/commands/autoupdate.py b/pre_commit/commands/autoupdate.py index d5352e5e..bc770eea 100644 --- a/pre_commit/commands/autoupdate.py +++ b/pre_commit/commands/autoupdate.py @@ -6,6 +6,8 @@ from typing import Any from typing import NamedTuple from typing import Sequence +from semver import VersionInfo + import pre_commit.constants as C from pre_commit import git from pre_commit import output @@ -24,6 +26,17 @@ from pre_commit.util import yaml_dump from pre_commit.util import yaml_load +def _semver_parse(s: str) -> VersionInfo: + try: + return VersionInfo.parse(s) + except ValueError: + return VersionInfo(0, build=s) + + +def _semver_is_stable(s: VersionInfo) -> bool: + return s == s.finalize_version() + + class RevInfo(NamedTuple): repo: str rev: str @@ -33,42 +46,73 @@ class RevInfo(NamedTuple): def from_config(cls, config: dict[str, Any]) -> RevInfo: return cls(config['repo'], config['rev'], None) - def update(self, tags_only: bool, freeze: bool) -> RevInfo: + def update_semver(self, stable_only: bool) -> str | None: + remote_tags = cmd_output('git', 'ls-remote', self.repo)[1] + all_tags = [ + row.rsplit('\t')[1] + for row in remote_tags.strip().split('\n') + ] + all_tags = [ + tag.removeprefix('refs/tags/').removesuffix('^{}') + for tag in all_tags + ] + all_versions = [(_semver_parse(tag), tag) for tag in all_tags] + if stable_only: + all_versions = [ + version for version in all_versions + if _semver_is_stable(version[0]) + ] + if not all_versions: + return None + newest_version = max(all_versions) + return newest_version[1] + + def update( + self, + tags_only: bool, + freeze: bool, + semver: bool, + semver_stable_only: bool, + ) -> RevInfo: git_cmd = ('git', *git.NO_FS_MONITOR) - if tags_only: - tag_cmd = ( - *git_cmd, 'describe', - 'FETCH_HEAD', '--tags', '--abbrev=0', - ) - else: - tag_cmd = ( - *git_cmd, 'describe', - 'FETCH_HEAD', '--tags', '--exact', - ) - - with tmpdir() as tmp: - git.init_repo(tmp, self.repo) - cmd_output_b( - *git_cmd, 'fetch', 'origin', 'HEAD', '--tags', - cwd=tmp, - ) - - try: - rev = cmd_output(*tag_cmd, cwd=tmp)[1].strip() - except CalledProcessError: - cmd = (*git_cmd, 'rev-parse', 'FETCH_HEAD') - rev = cmd_output(*cmd, cwd=tmp)[1].strip() + rev = None + if semver: + rev = self.update_semver(semver_stable_only) + if rev is None: + if tags_only: + tag_cmd = ( + *git_cmd, 'describe', + 'FETCH_HEAD', '--tags', '--abbrev=0', + ) else: - if tags_only: - rev = git.get_best_candidate_tag(rev, tmp) + tag_cmd = ( + *git_cmd, 'describe', + 'FETCH_HEAD', '--tags', '--exact', + ) - frozen = None - if freeze: - exact_rev_cmd = (*git_cmd, 'rev-parse', rev) - exact = cmd_output(*exact_rev_cmd, cwd=tmp)[1].strip() - if exact != rev: - rev, frozen = exact, rev + with tmpdir() as tmp: + git.init_repo(tmp, self.repo) + cmd_output_b( + *git_cmd, 'fetch', 'origin', 'HEAD', '--tags', + cwd=tmp, + ) + + try: + rev = cmd_output(*tag_cmd, cwd=tmp)[1].strip() + except CalledProcessError: + cmd = (*git_cmd, 'rev-parse', 'FETCH_HEAD') + rev = cmd_output(*cmd, cwd=tmp)[1].strip() + else: + if tags_only: + rev = git.get_best_candidate_tag(rev, tmp) + + frozen = None + if freeze: + exact_rev_cmd = (*git_cmd, 'rev-parse', rev) + exact = cmd_output(*exact_rev_cmd, cwd=tmp)[1].strip() + if exact != rev: + rev, frozen = exact, rev return self._replace(rev=rev, frozen=frozen) @@ -147,6 +191,8 @@ def autoupdate( config_file: str, store: Store, tags_only: bool, + semver: bool, + semver_stable_only: bool, freeze: bool, repos: Sequence[str] = (), ) -> int: @@ -167,7 +213,12 @@ def autoupdate( continue output.write(f'Updating {info.repo} ... ') - new_info = info.update(tags_only=tags_only, freeze=freeze) + new_info = info.update( + tags_only=tags_only, + freeze=freeze, + semver=semver, + semver_stable_only=semver_stable_only, + ) try: _check_hooks_still_exist_at_rev(repo_config, new_info, store) except RepositoryCannotBeUpdatedError as error: diff --git a/pre_commit/main.py b/pre_commit/main.py index 3915993f..abc6e81c 100644 --- a/pre_commit/main.py +++ b/pre_commit/main.py @@ -199,6 +199,18 @@ def main(argv: Sequence[str] | None = None) -> int: help="Auto-update pre-commit config to the latest repos' versions.", ) _add_config_option(autoupdate_parser) + autoupdate_parser.add_argument( + '--semver', action='store_true', + help=( + 'Use the highest version according to semantic versoning.' + ), + ) + autoupdate_parser.add_argument( + '--semver-prerelease', action='store_true', + help=( + 'Use pre-release versions according to semver.' + ), + ) autoupdate_parser.add_argument( '--bleeding-edge', action='store_true', help=( @@ -355,6 +367,8 @@ def main(argv: Sequence[str] | None = None) -> int: return autoupdate( args.config, store, tags_only=not args.bleeding_edge, + semver=args.semver, + semver_stable_only=not args.semver_prerelease, freeze=args.freeze, repos=args.repos, ) diff --git a/setup.cfg b/setup.cfg index dd0f9c9a..fe886c54 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,6 +23,7 @@ install_requires = identify>=1.0.0 nodeenv>=0.11.1 pyyaml>=5.1 + semver>=2.13.0 virtualenv>=20.10.0 importlib-metadata;python_version<"3.8" python_requires = >=3.7 diff --git a/tests/commands/autoupdate_test.py b/tests/commands/autoupdate_test.py index 3806b0e4..9072549c 100644 --- a/tests/commands/autoupdate_test.py +++ b/tests/commands/autoupdate_test.py @@ -69,7 +69,10 @@ 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) - new_info = info.update(tags_only=False, freeze=False) + new_info = info.update( + tags_only=False, freeze=False, + semver=False, semver_stable_only=False, + ) assert info == new_info @@ -78,7 +81,10 @@ def test_rev_info_update_out_of_date_repo(out_of_date): out_of_date.path, rev=out_of_date.original_rev, ) info = RevInfo.from_config(config) - new_info = info.update(tags_only=False, freeze=False) + new_info = info.update( + tags_only=False, freeze=False, + semver=False, semver_stable_only=False, + ) assert new_info.rev == out_of_date.head_rev @@ -91,7 +97,10 @@ def test_rev_info_update_non_master_default_branch(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) + new_info = info.update( + tags_only=False, freeze=False, + semver=False, semver_stable_only=False, + ) assert new_info.rev == 'v1.2.3' @@ -99,7 +108,10 @@ 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) + new_info = info.update( + tags_only=True, freeze=False, + semver=False, semver_stable_only=False, + ) assert new_info.rev == 'v1.2.3' @@ -107,7 +119,10 @@ def test_rev_info_update_tags_prefers_version_tag(tagged, out_of_date): cmd_output('git', 'tag', 'latest', cwd=out_of_date.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) + new_info = info.update( + tags_only=True, freeze=False, + semver=False, semver_stable_only=False, + ) assert new_info.rev == 'v1.2.3' @@ -117,15 +132,75 @@ def test_rev_info_update_tags_non_version_tag(out_of_date): out_of_date.path, rev=out_of_date.original_rev, ) info = RevInfo.from_config(config) - new_info = info.update(tags_only=True, freeze=False) + new_info = info.update( + tags_only=True, freeze=False, + semver=False, semver_stable_only=False, + ) assert new_info.rev == 'latest' +def test_rev_info_update_semver_non_version_tag(out_of_date): + cmd_output('git', 'tag', 'latest', cwd=out_of_date.path) + 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=False, + semver=True, semver_stable_only=True, + ) + assert new_info.rev == 'v1.2.3' + + +def test_rev_info_update_semver_prerelease_non_version_tag(out_of_date): + cmd_output('git', 'tag', 'latest', cwd=out_of_date.path) + 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=False, + semver=True, semver_stable_only=False, + ) + assert new_info.rev == 'v1.2.3' + + +def test_rev_info_update_semver_new_version_tags(out_of_date): + cmd_output('git', 'tag', 'v1.2.4', cwd=out_of_date.path) + cmd_output('git', 'tag', 'v1.2.5-pre.1', cwd=out_of_date.path) + 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=False, + semver=True, semver_stable_only=False, + ) + assert new_info.rev == 'v1.2.4' + + +def test_rev_info_update_semver_prerelease_new_version_tags(out_of_date): + cmd_output('git', 'tag', 'v1.2.4', cwd=out_of_date.path) + cmd_output('git', 'tag', 'v1.2.5-pre.1', cwd=out_of_date.path) + 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=False, + semver=True, semver_stable_only=False, + ) + assert new_info.rev == 'v1.2.5-pre.1' + + 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) + new_info = info.update( + tags_only=True, freeze=True, + semver=False, semver_stable_only=False, + ) assert new_info.rev == tagged.head_rev assert new_info.frozen == 'v1.2.3' @@ -135,7 +210,10 @@ def test_rev_info_update_does_not_freeze_if_already_sha(out_of_date): out_of_date.path, rev=out_of_date.original_rev, ) info = RevInfo.from_config(config) - new_info = info.update(tags_only=True, freeze=True) + new_info = info.update( + tags_only=True, freeze=True, + semver=False, semver_stable_only=False, + ) assert new_info.rev == out_of_date.head_rev assert new_info.frozen is None @@ -151,7 +229,10 @@ 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), store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, + ) == 0 assert cfg.read() == contents @@ -175,7 +256,10 @@ 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, store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, + ) == 0 with open(C.CONFIG_FILE) as f: after = f.read() assert before != after @@ -193,7 +277,10 @@ 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), store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, + ) == 0 assert cfg.read() == fmt.format(out_of_date.path, out_of_date.head_rev) @@ -229,7 +316,10 @@ 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), store, freeze=False, tags_only=False, + semver=False, semver_stable_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, @@ -251,6 +341,7 @@ def test_autoupdate_out_of_date_repo_with_correct_repo_name( repo_name = f'file://{out_of_date.path}' ret = autoupdate( C.CONFIG_FILE, store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, repos=(repo_name,), ) with open(C.CONFIG_FILE) as f: @@ -274,6 +365,7 @@ def test_autoupdate_out_of_date_repo_with_wrong_repo_name( # It will not update it, because the name doesn't match ret = autoupdate( C.CONFIG_FILE, store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, repos=('dne',), ) with open(C.CONFIG_FILE) as f: @@ -295,7 +387,10 @@ 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), store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, + ) == 0 expected = fmt.format(out_of_date.path, out_of_date.head_rev) assert cfg.read() == expected @@ -315,7 +410,10 @@ 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), store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, + ) == 0 assert cfg.read_binary() == expected @@ -334,7 +432,10 @@ 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), store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, + ) == 0 expected = fmt.format(out_of_date.path, out_of_date.head_rev).encode() assert cfg.read_binary() == expected @@ -360,7 +461,10 @@ 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), store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, + ) == 0 expected = ( f'repos:\n' f'- repo: {out_of_date.path}\n' @@ -375,7 +479,10 @@ def test_autoupdate_tagged_repo(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=False, tags_only=False) == 0 + assert autoupdate( + C.CONFIG_FILE, store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, + ) == 0 with open(C.CONFIG_FILE) as f: assert 'v1.2.3' in f.read() @@ -384,13 +491,19 @@ 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 + assert autoupdate( + C.CONFIG_FILE, store, freeze=True, tags_only=False, + semver=False, semver_stable_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, store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, + ) == 0 with open(C.CONFIG_FILE) as f: assert 'rev: v1.2.3\n' in f.read() @@ -402,7 +515,10 @@ def test_autoupdate_tags_only(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=False, tags_only=True) == 0 + assert autoupdate( + C.CONFIG_FILE, store, freeze=False, tags_only=True, + semver=False, semver_stable_only=False, + ) == 0 with open(C.CONFIG_FILE) as f: assert 'v1.2.3' in f.read() @@ -416,7 +532,10 @@ 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, store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, + ) == 1 with open(C.CONFIG_FILE) as f: assert out_of_date.original_rev in f.read() @@ -427,7 +546,10 @@ def test_hook_disppearing_repo_raises(hook_disappearing, store): rev=hook_disappearing.original_rev, hooks=[{'id': 'foo'}], ) - info = RevInfo.from_config(config).update(tags_only=False, freeze=False) + info = RevInfo.from_config(config).update( + tags_only=False, freeze=False, + semver=False, semver_stable_only=False, + ) with pytest.raises(RepositoryCannotBeUpdatedError): _check_hooks_still_exist_at_rev(config, info, store) @@ -443,14 +565,20 @@ 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), store, freeze=False, tags_only=False, + semver=False, semver_stable_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, freeze=False, tags_only=False) == 0 + assert autoupdate( + C.CONFIG_FILE, store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, + ) == 0 new_config_written = read_config('.') assert len(new_config_written['repos']) == 1 assert new_config_written['repos'][0] == config @@ -465,7 +593,10 @@ 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, store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, + ) == 0 new_config_written = read_config('.') assert len(new_config_written['repos']) == 2 assert new_config_written['repos'][0] == local_config @@ -479,7 +610,10 @@ 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), store, freeze=False, tags_only=True, + semver=False, semver_stable_only=False, + ) == 0 assert cfg.read() == ( 'repos:\n' '- repo: meta\n' @@ -498,7 +632,10 @@ 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), store, freeze=False, tags_only=True, + semver=False, semver_stable_only=False, + ) == 0 contents = cfg.read() assert contents == ( 'repos:\n' @@ -528,6 +665,9 @@ 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), store, freeze=False, tags_only=False, + semver=False, semver_stable_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 c128e939..a87a6760 100644 --- a/tests/commands/gc_test.py +++ b/tests/commands/gc_test.py @@ -44,7 +44,10 @@ 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, freeze=False, tags_only=False) + assert not autoupdate( + C.CONFIG_FILE, store, freeze=False, tags_only=False, + semver=False, semver_stable_only=False, + ) assert _config_count(store) == 1 assert _repo_count(store) == 2