diff --git a/commitizen/tags.py b/commitizen/tags.py index 68c74a72e..dcddbd335 100644 --- a/commitizen/tags.py +++ b/commitizen/tags.py @@ -228,7 +228,7 @@ def normalize_tag( version = self.scheme(version) if isinstance(version, str) else version tag_format = tag_format or self.tag_format - major, minor, patch = version.release + major, minor, patch = (list(version.release) + [0, 0, 0])[:3] prerelease = version.prerelease or "" t = Template(tag_format) @@ -245,6 +245,25 @@ def find_tag_for( ) -> GitTag | None: """Find the first matching tag for a given version.""" version = self.scheme(version) if isinstance(version, str) else version + release = version.release + + # 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[Version, 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)) + + if matching_versions: + _, latest_tag = max(matching_versions, key=lambda vt: vt[0]) + return latest_tag + 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 len(candidates) > 1: diff --git a/docs/commands/bump.md b/docs/commands/bump.md index e7a7c0403..03fc83402 100644 --- a/docs/commands/bump.md +++ b/docs/commands/bump.md @@ -65,6 +65,19 @@ You can also set this in the configuration file with `version_scheme = "semver"` | Devrelease | `0.1.1.dev1` | `0.1.1-dev1` | | Dev and pre | `1.0.0a3.dev1` | `1.0.0-a3-dev1` | + +!!! note "Incomplete Version Handling" + Commitizen treats a three-part version (major.minor.patch) as complete. + If your configured version is incomplete (for example, `1` or `1.2`), Commitizen pads missing parts with zeros when it needs `major/minor/patch` for tag formatting. + The tag output depends on your `tag_format`: formats using `${version}` keep `1`/`1.2`, while formats using `${major}.${minor}.${patch}` will render `1.0.0`/`1.2.0`. + + When bumping from an incomplete version, Commitizen looks for the latest existing tag that matches the provided release prefix. + For example, if the current version is `1.2` and the latest `1.2.x` tag is `1.2.3`, then a patch bump yields `1.2.4` and a minor bump yields `1.3.0`. + +!!! tip + To control the behaviour of bumping and version parsing, you may implement your own `version_scheme` by inheriting from `commitizen.version_schemes.BaseVersion` or use an existing plugin package. + + ### PEP440 Version Examples Commitizen supports the [PEP 440][pep440] version format, which includes several version types. Here are examples of each: diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index a1c70b948..b9a6bf90f 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -755,6 +755,8 @@ def test_bump_invalid_manual_version_raises_exception( "0.1.1", "0.2.0", "1.0.0", + "1.2", + "1", ], ) def test_bump_manual_version(util: UtilFixture, manual_version): @@ -775,6 +777,33 @@ def test_bump_manual_version_disallows_major_version_zero(util: UtilFixture): ) +@pytest.mark.parametrize( + "initial_version, expected_version_after_bump", + [ + ("1", "1.1.0"), + ("1.2", "1.3.0"), + ], +) +def test_bump_version_with_less_components_in_config( + tmp_commitizen_project_initial, + initial_version, + expected_version_after_bump, + util: UtilFixture, +): + tmp_commitizen_project = tmp_commitizen_project_initial(version=initial_version) + util.run_cli("bump", "--yes") + + tag_exists = git.tag_exist(expected_version_after_bump) + assert tag_exists is True + + for version_file in [ + tmp_commitizen_project.join("__version__.py"), + tmp_commitizen_project.join("pyproject.toml"), + ]: + with open(version_file) as f: + assert expected_version_after_bump in f.read() + + @pytest.mark.parametrize("commit_msg", ("feat: new file", "feat(user): new file")) def test_bump_with_pre_bump_hooks( commit_msg, mocker: MockFixture, tmp_commitizen_project, util: UtilFixture diff --git a/tests/test_tags.py b/tests/test_tags.py new file mode 100644 index 000000000..2471b8461 --- /dev/null +++ b/tests/test_tags.py @@ -0,0 +1,101 @@ +from commitizen.git import GitTag +from commitizen.tags import TagRules + + +def _git_tag(name: str) -> GitTag: + return GitTag(name, "rev", "2024-01-01") + + +def test_find_tag_for_partial_version_returns_latest_match(): + tags = [ + _git_tag("1.2.0"), + _git_tag("1.2.2"), + _git_tag("1.2.1"), + _git_tag("1.3.0"), + ] + + rules = TagRules() + + found = rules.find_tag_for(tags, "1.2") + + assert found is not None + assert found.name == "1.2.2" + + +def test_find_tag_for_full_version_remains_exact(): + tags = [ + _git_tag("1.2.0"), + _git_tag("1.2.2"), + _git_tag("1.2.1"), + ] + + rules = TagRules() + + found = rules.find_tag_for(tags, "1.2.1") + + assert found is not None + assert found.name == "1.2.1" + + +def test_find_tag_for_partial_version_with_prereleases_prefers_latest_version(): + tags = [ + _git_tag("1.2.0b1"), + _git_tag("1.2.0"), + _git_tag("1.2.1b1"), + ] + + rules = TagRules() + + found = rules.find_tag_for(tags, "1.2") + + assert found is not None + # 1.2.1b1 > 1.2.0 so it should be selected + assert found.name == "1.2.1b1" + + +def test_find_tag_for_partial_version_respects_tag_format(): + tags = [ + _git_tag("v1.2.0"), + _git_tag("v1.2.1"), + _git_tag("v1.3.0"), + ] + + rules = TagRules(tag_format="v$version") + + found = rules.find_tag_for(tags, "1.2") + + assert found is not None + assert found.name == "v1.2.1" + + found = rules.find_tag_for(tags, "1") + + assert found is not None + assert found.name == "v1.3.0" + + +def test_find_tag_for_partial_version_returns_none_when_no_match(): + tags = [ + _git_tag("2.0.0"), + _git_tag("2.1.0"), + ] + + rules = TagRules() + + found = rules.find_tag_for(tags, "1.2") + + assert found is None + + +def test_find_tag_for_partial_version_ignores_invalid_tags(): + tags = [ + _git_tag("not-a-version"), + _git_tag("1.2.0"), + _git_tag("1.2.1"), + ] + + rules = TagRules() + + found = rules.find_tag_for(tags, "1.2") + + assert found is not None + assert found.name == "1.2.1"