From f0dc83562b03f904b90baf44993b8037f42ea416 Mon Sep 17 00:00:00 2001 From: Chad Dombrova Date: Mon, 29 Jan 2024 13:06:25 -0800 Subject: [PATCH] fix(scm): only search tags that are reachable by the current commit The scm provider requires tags to be the source of truth for the current version, instead of files. Therefore, the current version should be the highest tag version upstream from the current commit. --- commitizen/commands/bump.py | 2 +- commitizen/git.py | 10 ++++- commitizen/providers/scm_provider.py | 36 +++++++++++++----- commitizen/version_schemes.py | 20 +++++++++- tests/providers/test_scm_provider.py | 56 +++++++++++++++++++++++++++- tests/test_git.py | 31 ++++++++++++++- 6 files changed, 138 insertions(+), 17 deletions(-) diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index b2ecd3130f..023d4f805b 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -421,7 +421,7 @@ def find_previous_final_version( if not final_versions: return None - final_versions = sorted(final_versions) # type: ignore [type-var] + final_versions = sorted(final_versions) current_index = final_versions.index(current_version) previous_index = current_index - 1 if previous_index < 0: diff --git a/commitizen/git.py b/commitizen/git.py index 4c4dfdb961..6cdc7e2752 100644 --- a/commitizen/git.py +++ b/commitizen/git.py @@ -164,7 +164,9 @@ def get_filenames_in_commit(git_reference: str = ""): raise GitCommandError(c.err) -def get_tags(dateformat: str = "%Y-%m-%d") -> list[GitTag]: +def get_tags( + dateformat: str = "%Y-%m-%d", reachable_only: bool = False +) -> list[GitTag]: inner_delimiter = "---inner_delimiter---" formatter = ( f'"%(refname:lstrip=2){inner_delimiter}' @@ -172,8 +174,12 @@ def get_tags(dateformat: str = "%Y-%m-%d") -> list[GitTag]: f"%(creatordate:format:{dateformat}){inner_delimiter}" f'%(object)"' ) - c = cmd.run(f"git tag --format={formatter} --sort=-creatordate") + extra = "--merged" if reachable_only else "" + c = cmd.run(f"git tag --format={formatter} --sort=-creatordate {extra}") if c.return_code != 0: + if reachable_only and c.err == "fatal: malformed object name HEAD\n": + # this can happen if there are no commits in the repo yet + return [] raise GitCommandError(c.err) if c.err: diff --git a/commitizen/providers/scm_provider.py b/commitizen/providers/scm_provider.py index bc9dda4b8a..37329d07a5 100644 --- a/commitizen/providers/scm_provider.py +++ b/commitizen/providers/scm_provider.py @@ -1,11 +1,16 @@ from __future__ import annotations import re -from typing import Callable, cast +from typing import Callable from commitizen.git import get_tags -from commitizen.version_schemes import get_version_scheme +from commitizen.version_schemes import ( + get_version_scheme, + InvalidVersion, + Version, + VersionProtocol, +) from commitizen.providers.base_provider import VersionProvider @@ -28,7 +33,7 @@ class ScmProvider(VersionProvider): "$devrelease": r"(?P\.dev\d+)?", } - def _tag_format_matcher(self) -> Callable[[str], str | None]: + def _tag_format_matcher(self) -> Callable[[str], VersionProtocol | None]: version_scheme = get_version_scheme(self.config) pattern = self.config.settings["tag_format"] if pattern == "$version": @@ -38,15 +43,15 @@ def _tag_format_matcher(self) -> Callable[[str], str | None]: regex = re.compile(f"^{pattern}$", re.VERBOSE) - def matcher(tag: str) -> str | None: + def matcher(tag: str) -> Version | None: match = regex.match(tag) if not match: return None groups = match.groupdict() if "version" in groups: - return groups["version"] + ver = groups["version"] elif "major" in groups: - return "".join( + ver = "".join( ( groups["major"], f".{groups['minor']}" if groups.get("minor") else "", @@ -56,16 +61,27 @@ def matcher(tag: str) -> str | None: ) ) elif pattern == version_scheme.parser.pattern: - return str(version_scheme(tag)) - return None + ver = tag + else: + return None + + try: + return version_scheme(ver) + except InvalidVersion: + return None return matcher def get_version(self) -> str: matcher = self._tag_format_matcher() - return next( - (cast(str, matcher(t.name)) for t in get_tags() if matcher(t.name)), "0.0.0" + matches = sorted( + version + for t in get_tags(reachable_only=True) + if (version := matcher(t.name)) ) + if not matches: + return "0.0.0" + return str(matches[-1]) def set_version(self, version: str): # Not necessary diff --git a/commitizen/version_schemes.py b/commitizen/version_schemes.py index c277d2efc4..8fcec9e74f 100644 --- a/commitizen/version_schemes.py +++ b/commitizen/version_schemes.py @@ -4,7 +4,7 @@ import sys import warnings from itertools import zip_longest -from typing import TYPE_CHECKING, ClassVar, Protocol, Type, cast, runtime_checkable +from typing import TYPE_CHECKING, Any, ClassVar, Protocol, Type, cast, runtime_checkable import importlib_metadata as metadata from packaging.version import InvalidVersion # noqa: F401: Rexpose the common exception @@ -93,6 +93,24 @@ def micro(self) -> int: """The third item of :attr:`release` or ``0`` if unavailable.""" raise NotImplementedError("must be implemented") + def __lt__(self, other: Any) -> bool: + raise NotImplementedError("must be implemented") + + def __le__(self, other: Any) -> bool: + raise NotImplementedError("must be implemented") + + def __eq__(self, other: object) -> bool: + raise NotImplementedError("must be implemented") + + def __ge__(self, other: Any) -> bool: + raise NotImplementedError("must be implemented") + + def __gt__(self, other: Any) -> bool: + raise NotImplementedError("must be implemented") + + def __ne__(self, other: object) -> bool: + raise NotImplementedError("must be implemented") + def bump( self, increment: str, diff --git a/tests/providers/test_scm_provider.py b/tests/providers/test_scm_provider.py index a0bfc46474..9611dd9ee0 100644 --- a/tests/providers/test_scm_provider.py +++ b/tests/providers/test_scm_provider.py @@ -6,7 +6,13 @@ from commitizen.config.base_config import BaseConfig from commitizen.providers import get_provider from commitizen.providers.scm_provider import ScmProvider -from tests.utils import create_file_and_commit, create_tag +from tests.utils import ( + create_branch, + create_file_and_commit, + create_tag, + merge_branch, + switch_branch, +) @pytest.mark.parametrize( @@ -22,7 +28,8 @@ # much more lenient but require a v prefix. ("v$version", "v0.1.0", "0.1.0"), ("v$version", "no-match-because-no-v-prefix", "0.0.0"), - ("v$version", "v-match-TAG_FORMAT_REGEXS", "-match-TAG_FORMAT_REGEXS"), + # no match because not a valid version + ("v$version", "v-match-TAG_FORMAT_REGEXS", "0.0.0"), ("version-$version", "version-0.1.0", "0.1.0"), ("version-$version", "version-0.1", "0.1"), ("version-$version", "version-0.1.0rc1", "0.1.0rc1"), @@ -62,3 +69,48 @@ def test_scm_provider_default_without_commits_and_tags(config: BaseConfig): provider = get_provider(config) assert isinstance(provider, ScmProvider) assert provider.get_version() == "0.0.0" + + +@pytest.mark.usefixtures("tmp_git_project") +def test_scm_provider_default_with_commits_and_tags(config: BaseConfig): + config.settings["version_provider"] = "scm" + + provider = get_provider(config) + assert isinstance(provider, ScmProvider) + assert provider.get_version() == "0.0.0" + + create_file_and_commit("Initial state") + create_tag("1.0.0") + # create develop + create_branch("develop") + switch_branch("develop") + + # add a feature to develop + create_file_and_commit("develop: add beta feature1") + assert provider.get_version() == "1.0.0" + create_tag("1.1.0b0") + + # create staging + create_branch("staging") + switch_branch("staging") + create_file_and_commit("staging: Starting release candidate") + assert provider.get_version() == "1.1.0b0" + create_tag("1.1.0rc0") + + # add another feature to develop + switch_branch("develop") + create_file_and_commit("develop: add beta feature2") + assert provider.get_version() == "1.1.0b0" + create_tag("1.2.0b0") + + # add a hotfix to master + switch_branch("master") + create_file_and_commit("master: add hotfix") + assert provider.get_version() == "1.0.0" + create_tag("1.0.1") + + # merge the hotfix to staging + switch_branch("staging") + merge_branch("master") + + assert provider.get_version() == "1.1.0rc0" diff --git a/tests/test_git.py b/tests/test_git.py index 4ae11a45dd..0d0d9ba09e 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -9,7 +9,13 @@ from commitizen import cmd, exceptions, git from pytest_mock import MockFixture -from tests.utils import FakeCommand, create_file_and_commit, create_tag +from tests.utils import ( + FakeCommand, + create_file_and_commit, + create_tag, + create_branch, + switch_branch, +) def test_git_object_eq(): @@ -42,6 +48,29 @@ def test_get_tags(mocker: MockFixture): assert git.get_tags() == [] +def test_get_reachable_tags(tmp_commitizen_project): + with tmp_commitizen_project.as_cwd(): + create_file_and_commit("Initial state") + create_tag("1.0.0") + # create develop + create_branch("develop") + switch_branch("develop") + + # add a feature to develop + create_file_and_commit("develop") + create_tag("1.1.0b0") + + # create staging + switch_branch("master") + create_file_and_commit("master") + create_tag("1.0.1") + + tags = git.get_tags(reachable_only=True) + tag_names = [t.name for t in tags] + # 1.1.0b0 is not present + assert tag_names == ["1.0.0", "1.0.1"] + + def test_get_tag_names(mocker: MockFixture): tag_str = "v1.0.0\n" "v0.5.0\n" "v0.0.1\n" mocker.patch("commitizen.cmd.run", return_value=FakeCommand(out=tag_str))