From 17f106c78ed31814fc911003fa1463c273f4a611 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/git.py | 10 ++++- commitizen/providers/scm_provider.py | 31 ++++++++++----- commitizen/version_schemes.py | 18 +++++++++ tests/providers/test_scm_provider.py | 56 +++++++++++++++++++++++++++- 4 files changed, 101 insertions(+), 14 deletions(-) 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..eb85875ba5 100644 --- a/commitizen/providers/scm_provider.py +++ b/commitizen/providers/scm_provider.py @@ -1,11 +1,11 @@ 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 +28,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 +38,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 +56,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 d7967ae928..3175df3667 100644 --- a/commitizen/version_schemes.py +++ b/commitizen/version_schemes.py @@ -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: VersionProtocol) -> bool: + raise NotImplementedError("must be implemented") + + def __le__(self, other: VersionProtocol) -> bool: + raise NotImplementedError("must be implemented") + + def __eq__(self, other: object) -> bool: + raise NotImplementedError("must be implemented") + + def __ge__(self, other: VersionProtocol) -> bool: + raise NotImplementedError("must be implemented") + + def __gt__(self, other: VersionProtocol) -> 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"