diff --git a/commitizen/tags.py b/commitizen/tags.py index b3bcbe7a0..11a935b1c 100644 --- a/commitizen/tags.py +++ b/commitizen/tags.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: import sys - from collections.abc import Iterable, Sequence + from collections.abc import Iterable, Iterator, Sequence # Self is Python 3.11+ but backported in typing-extensions if sys.version_info < (3, 11): @@ -232,15 +232,11 @@ def find_tag_for( # If the requested version is incomplete (e.g., "1.2"), try to find the latest # matching tag that shares the provided prefix. if len(release) < 3: - matching_versions: list[tuple[VersionProtocol, GitTag]] = [] - for tag in tags: - try: - tag_version = self.extract_version(tag) - except InvalidVersion: - continue - if tag_version.release[: len(release)] != release: - continue - matching_versions.append((tag_version, tag)) + matching_versions = [ + (tag_version, tag) + for tag_version, tag in self._parse_tag_versions(tags) + if tag_version.release[: len(release)] == release + ] if matching_versions: _, latest_tag = max(matching_versions, key=lambda vt: vt[0]) @@ -248,6 +244,14 @@ def find_tag_for( possible_tags = set(self.normalize_tag(version, f) for f in self.tag_formats) candidates = [t for t in tags if t.name in possible_tags] + if not candidates: + # Tag formats with regex-only parts (e.g. `\+.*`) cannot be rendered + # by `normalize_tag`, so fall back to comparing extracted versions. + candidates = [ + tag + for tag_version, tag in self._parse_tag_versions(tags) + if tag_version == version + ] if len(candidates) > 1: warnings.warn( UserWarning( @@ -279,3 +283,19 @@ def _extract_version(self, match: re.Match[str]) -> str: if devrelease := groups.get("devrelease"): parts.append(devrelease) return "".join(parts) + + def _parse_tag_versions( + self, tags: Iterable[GitTag] + ) -> Iterator[tuple[VersionProtocol, GitTag]]: + """ + Yield each tag with its extracted version, skipping invalid tags. + + Used when searching tags by version: a tag that matches no tag format, + or whose version is rejected by the version scheme, is unrelated to the + search and must not abort it. + """ + for tag in tags: + try: + yield self.extract_version(tag), tag + except InvalidVersion: + continue diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index b26f095c9..eeeaa4ec5 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -1345,6 +1345,43 @@ def test_bump_detect_legacy_tags_from_scm( assert git.tag_exist("v0.4.3") is True +def test_bump_detect_legacy_tag_with_build_metadata_from_scm( + tmp_commitizen_project: Path, util: UtilFixture, capsys: pytest.CaptureFixture +): + """Regression test for #2015.""" + tmp_commitizen_cfg_file = tmp_commitizen_project / "pyproject.toml" + tmp_commitizen_cfg_file.write_text( + "\n".join( + [ + "[tool.commitizen]", + 'version_provider = "scm"', + 'version_scheme = "pep440"', + 'tag_format = "$version"', + "legacy_tag_formats = [", + " '$major.$minor.$patch$prerelease\\+.*'", + "]", + ] + ), + ) + util.create_file_and_commit("feat: initial") + util.create_tag("1.0.1rc0+gha") + util.create_file_and_commit("fix: bar") + + with pytest.raises(DryRunExit): + util.run_cli( + "bump", + "--yes", + "--prerelease", + "rc", + "--build-metadata", + "gha", + "--get-next", + ) + + out, _ = capsys.readouterr() + assert out.strip() == "1.0.1rc1+gha" + + def test_bump_warn_but_dont_fail_on_invalid_tags( tmp_commitizen_project: Path, util: UtilFixture, diff --git a/tests/test_tags.py b/tests/test_tags.py index 3d5f8fc35..8a5130067 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -101,6 +101,27 @@ def test_find_tag_for_partial_version_ignores_invalid_tags(): assert found.name == "1.2.1" +def test_find_tag_for_full_version_matching_regex_only_legacy_tag_format(): + """Regression test for #2015: a legacy tag format containing regex-only + parts (here ``\\+.*`` for build metadata) cannot be rendered back into a tag + name, so the tag must be found by comparing its extracted version. + """ + tags = [ + _git_tag("not-a-version"), + _git_tag("1.2.3foo"), + _git_tag("1.0.0"), + _git_tag("1.0.1rc0+gha"), + ] + + rules = TagRules(legacy_tag_formats=[r"$major.$minor.$patch$prerelease\+.*"]) + + found = rules.find_tag_for(tags, "1.0.1rc0") + + assert found is not None + assert found.name == "1.0.1rc0+gha" + assert rules.find_tag_for(tags, "1.0.2") is None + + def test_is_version_tag_accepts_semver2_prerelease_in_custom_tag_format(): """Regression test for #1614: a SemVer2-style prerelease segment such as ``rc.0`` (with a literal dot) must be recognised when it appears at the