diff --git a/commitizen/cli.py b/commitizen/cli.py index ed89b5675..355a37b1c 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -1,8 +1,8 @@ import argparse import logging import sys -from pathlib import Path from functools import partial +from pathlib import Path from types import TracebackType from typing import List @@ -274,6 +274,13 @@ "If not set, it will include prereleases in the changelog" ), }, + { + "name": "--tag-regex", + "help": ( + "regex match for tags represented " + "within the changelog. default: '.*'" + ), + }, ], }, { diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index 84253cef7..24685face 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -1,4 +1,5 @@ import os.path +import re from difflib import SequenceMatcher from operator import itemgetter from typing import Callable, Dict, List, Optional @@ -17,7 +18,7 @@ NotAllowed, ) from commitizen.git import GitTag, smart_open -from commitizen.tags import tag_from_version +from commitizen.tags import make_tag_pattern, tag_from_version class Changelog: @@ -67,6 +68,11 @@ def __init__(self, config: BaseConfig, args): version_type = self.config.settings.get("version_type") self.version_type = version_type and version_types.VERSION_TYPES[version_type] + tag_regex = args.get("tag_regex") or self.config.settings.get("tag_regex") + if not tag_regex: + tag_regex = make_tag_pattern(self.tag_format) + self.tag_pattern = re.compile(str(tag_regex), re.VERBOSE | re.IGNORECASE) + def _find_incremental_rev(self, latest_version: str, tags: List[GitTag]) -> str: """Try to find the 'start_rev'. @@ -140,7 +146,7 @@ def __call__(self): # Don't continue if no `file_name` specified. assert self.file_name - tags = git.get_tags() + tags = git.get_tags(pattern=self.tag_pattern) if not tags: tags = [] diff --git a/commitizen/git.py b/commitizen/git.py index 2c2cb5b36..3eb8b33a8 100644 --- a/commitizen/git.py +++ b/commitizen/git.py @@ -1,4 +1,5 @@ import os +import re from enum import Enum from os import linesep from pathlib import Path @@ -140,7 +141,7 @@ 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", *, pattern: re.Pattern) -> List[GitTag]: inner_delimiter = "---inner_delimiter---" formatter = ( f'"%(refname:lstrip=2){inner_delimiter}' @@ -163,7 +164,9 @@ def get_tags(dateformat: str = "%Y-%m-%d") -> List[GitTag]: for line in c.out.split("\n")[:-1] ] - return git_tags + filtered_git_tags = [t for t in git_tags if pattern.fullmatch(t.name)] + + return filtered_git_tags def tag_exist(tag: str) -> bool: diff --git a/commitizen/tags.py b/commitizen/tags.py index e13762125..32ac6f890 100644 --- a/commitizen/tags.py +++ b/commitizen/tags.py @@ -1,8 +1,9 @@ +import re import sys from string import Template from typing import Any, Optional, Type, Union -from packaging.version import Version +from packaging.version import VERSION_PATTERN, Version if sys.version_info >= (3, 8): from commitizen.version_types import VersionProtocol @@ -42,3 +43,23 @@ def tag_from_version( return t.safe_substitute( version=version, major=major, minor=minor, patch=patch, prerelease=prerelease ) + + +def make_tag_pattern(tag_format: str) -> str: + """Make regex pattern to match all tags created by tag_format.""" + escaped_format = re.escape(tag_format) + escaped_format = re.sub( + r"\\\$(version|major|minor|patch|prerelease)", r"$\1", escaped_format + ) + # pre-release part of VERSION_PATTERN + pre_release_pattern = r"([-_\.]?(a|b|c|rc|alpha|beta|pre|preview)([-_\.]?[0-9]+)?)?" + filter_regex = Template(escaped_format).safe_substitute( + # VERSION_PATTERN allows the v prefix, but we'd rather have users configure it + # explicitly. + version=VERSION_PATTERN.lstrip("\n v?"), + major="[0-9]+", + minor="[0-9]+", + patch="[0-9]+", + prerelease=pre_release_pattern, + ) + return filter_regex diff --git a/docs/changelog.md b/docs/changelog.md index d6799e198..24730dabc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -186,6 +186,28 @@ cz changelog --merge-prerelease changelog_merge_prerelease = true ``` +### `tag-regex` + +This value can be set in the `toml` file with the key `tag_regex` under `tools.commitizen`. + +`tag_regex` is the regex pattern that selects tags to include in the changelog. +By default, the changelog will capture all git tags matching the `tag_format`, including pre-releases. + +Example use-cases: + +- Exclude pre-releases from the changelog +- Include existing tags that do not follow `tag_format` in the changelog + +```bash +cz changelog --tag-regex="[0-9]*\\.[0-9]*\\.[0-9]" +``` + +```toml +[tools.commitizen] +# ... +tag_regex = "[0-9]*\\.[0-9]*\\.[0-9]" +``` + ## Hooks Supported hook methods: diff --git a/docs/config.md b/docs/config.md index 5bb9195a8..07f36ebc1 100644 --- a/docs/config.md +++ b/docs/config.md @@ -42,6 +42,14 @@ Default: `$version` Format for the git tag, useful for old projects, that use a convention like `"v1.2.1"`. [Read more][tag_format] +### `tag_regex` + +Type: `str` + +Default: Based on `tag_format` + +Tags must match this to be included in the changelog (e.g. `"([0-9.])*"` to exclude pre-releases). [Read more][tag_regex] + ### `update_changelog_on_bump` Type: `bool` @@ -339,6 +347,7 @@ setup( [version_files]: bump.md#version_files [tag_format]: bump.md#tag_format +[tag_regex]: changelog.md#tag_regex [bump_message]: bump.md#bump_message [major-version-zero]: bump.md#-major-version-zero [prerelease-offset]: bump.md#-prerelease_offset diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 000000000..ab1033bd3 --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index f11a485db..6468bdfe8 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -533,6 +533,24 @@ def test_bump_with_changelog_config(mocker: MockFixture, changelog_path, config_ assert "0.2.0" in out +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_with_changelog_excludes_custom_tags(mocker: MockFixture, changelog_path): + create_file_and_commit("feat(user): new file") + git.tag("custom-tag") + create_file_and_commit("feat(user): Another new file") + testargs = ["cz", "bump", "--yes", "--changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + with open(changelog_path, "r") as f: + out = f.read() + assert out.startswith("#") + assert "## 0.2.0" in out + assert "custom-tag" not in out + + @pytest.mark.usefixtures("tmp_commitizen_project") def test_prevent_prerelease_when_no_increment_detected(mocker: MockFixture, capsys): create_file_and_commit("feat: new file") diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index 30033d9c7..6c215138d 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -1,6 +1,8 @@ import itertools import sys from datetime import datetime +from typing import List +from unittest.mock import patch import pytest from pytest_mock import MockFixture @@ -1271,3 +1273,53 @@ def test_changelog_prerelease_rev_with_use_version_type_semver( out, _ = capsys.readouterr() file_regression.check(out, extension=".second-prerelease.md") + + +@pytest.mark.parametrize( + "config_file, expected_versions", + [ + pytest.param("", ["Unreleased"], id="v-prefix-not-configured"), + pytest.param( + 'tag_format = "v$version"', + ["v1.1.0", "v1.1.0-beta", "v1.0.0"], + id="v-prefix-configured-as-tag-format", + ), + pytest.param( + 'tag_format = "v$version"\n' + 'tag_regex = ".*"', + ["v1.1.0", "custom-tag", "v1.1.0-beta", "v1.0.0"], + id="tag-regex-matches-all-tags", + ), + pytest.param( + 'tag_format = "v$version"\n' + r'tag_regex = "v[0-9\\.]*"', + ["v1.1.0", "v1.0.0"], + id="tag-regex-excludes-pre-releases", + ), + ], +) +def test_changelog_tag_regex( + config_path, changelog_path, config_file: str, expected_versions: List[str] +): + with open(config_path, "a") as f: + f.write(config_file) + + # Create 4 tags with one valid feature each + create_file_and_commit("feat: initial") + git.tag("v1.0.0") + create_file_and_commit("feat: add 1") + git.tag("v1.1.0-beta") + create_file_and_commit("feat: add 2") + git.tag("custom-tag") + create_file_and_commit("feat: add 3") + git.tag("v1.1.0") + + # call CLI + with patch.object(sys, "argv", ["cz", "changelog"]): + cli.main() + + # open CLI output + with open(changelog_path, "r") as f: + out = f.read() + + headings = [line for line in out.splitlines() if line.startswith("## ")] + changelog_versions = [heading[3:].split()[0] for heading in headings] + assert changelog_versions == expected_versions diff --git a/tests/test_git.py b/tests/test_git.py index 81089f675..cadf89e98 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -1,5 +1,6 @@ import inspect import os +import re import shutil from typing import List, Optional @@ -7,6 +8,7 @@ from pytest_mock import MockFixture from commitizen import cmd, exceptions, git +from commitizen.tags import make_tag_pattern from tests.utils import FakeCommand, create_file_and_commit @@ -28,7 +30,7 @@ def test_get_tags(mocker: MockFixture): ) mocker.patch("commitizen.cmd.run", return_value=FakeCommand(out=tag_str)) - git_tags = git.get_tags() + git_tags = git.get_tags(pattern=re.compile(r"v[0-9\.]+")) latest_git_tag = git_tags[0] assert latest_git_tag.rev == "333" assert latest_git_tag.name == "v1.0.0" @@ -37,7 +39,60 @@ def test_get_tags(mocker: MockFixture): mocker.patch( "commitizen.cmd.run", return_value=FakeCommand(out="", err="No tag available") ) - assert git.get_tags() == [] + assert git.get_tags(pattern=re.compile(r"v[0-9\.]+")) == [] + + +@pytest.mark.parametrize( + "pattern, expected_tags", + [ + pytest.param( + make_tag_pattern(tag_format="$version"), + [], # No versions with normal 1.2.3 pattern + id="default-tag-format", + ), + pytest.param( + make_tag_pattern(tag_format="$major-$minor-$patch$prerelease"), + ["1-0-0", "1-0-0alpha2"], + id="tag-format-with-hyphens", + ), + pytest.param( + r"[0-9]+\-[0-9]+\-[0-9]+", + ["1-0-0"], + id="tag-regex-with-hyphens-that-excludes-alpha", + ), + pytest.param( + make_tag_pattern(tag_format="v$version"), + ["v0.5.0", "v0.0.1-pre"], + id="tag-format-with-v-prefix", + ), + pytest.param( + make_tag_pattern(tag_format="custom-prefix-$version"), + ["custom-prefix-0.0.1"], + id="tag-format-with-custom-prefix", + ), + pytest.param( + ".*", + ["1-0-0", "1-0-0alpha2", "v0.5.0", "v0.0.1-pre", "custom-prefix-0.0.1"], + id="custom-tag-regex-to-include-all-tags", + ), + ], +) +def test_get_tags_filtering( + mocker: MockFixture, pattern: str, expected_tags: List[str] +): + tag_str = ( + "1-0-0---inner_delimiter---333---inner_delimiter---2020-01-20---inner_delimiter---\n" + "1-0-0alpha2---inner_delimiter---333---inner_delimiter---2020-01-20---inner_delimiter---\n" + "v0.5.0---inner_delimiter---222---inner_delimiter---2020-01-17---inner_delimiter---\n" + "v0.0.1-pre---inner_delimiter---111---inner_delimiter---2020-01-17---inner_delimiter---\n" + "custom-prefix-0.0.1---inner_delimiter---111---inner_delimiter---2020-01-17---inner_delimiter---\n" + "custom-non-release-tag" + ) + mocker.patch("commitizen.cmd.run", return_value=FakeCommand(out=tag_str)) + + git_tags = git.get_tags(pattern=re.compile(pattern, flags=re.VERBOSE)) + actual_name_list = [t.name for t in git_tags] + assert actual_name_list == expected_tags def test_get_tag_names(mocker: MockFixture): diff --git a/tests/test_tags.py b/tests/test_tags.py index 8684ba60d..efebe43f3 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,23 +1,76 @@ +import re +from typing import Dict + import pytest from packaging.version import Version -from commitizen.tags import tag_from_version - -conversion = [ - (("1.2.3", "v$version"), "v1.2.3"), - (("1.2.3a2", "v$version"), "v1.2.3a2"), - (("1.2.3b2", "v$version"), "v1.2.3b2"), - (("1.2.3", "ver$major.$minor.$patch"), "ver1.2.3"), - (("1.2.3a0", "ver$major.$minor.$patch.$prerelease"), "ver1.2.3.a0"), - (("1.2.3rc2", "$major.$minor.$patch.$prerelease-majestic"), "1.2.3.rc2-majestic"), - (("1.2.3+1.0.0", "v$version"), "v1.2.3+1.0.0"), - (("1.2.3+1.0.0", "v$version-local"), "v1.2.3+1.0.0-local"), - (("1.2.3+1.0.0", "ver$major.$minor.$patch"), "ver1.2.3"), -] - - -@pytest.mark.parametrize("test_input,expected", conversion) -def test_tag_from_version(test_input, expected): - version, format = test_input - new_tag = tag_from_version(Version(version), format) - assert new_tag == expected +from commitizen.tags import make_tag_pattern, tag_from_version + +TAG_FORMATS: Dict[str, Dict[str, list]] = { + "v$version": { + "tags_per_version": [ + ("1.2.3", "v1.2.3"), + ("1.2.3a2", "v1.2.3a2"), + ("1.2.3b2", "v1.2.3b2"), + ("1.2.3+1.0.0", "v1.2.3+1.0.0"), + ], + "invalid_tags": ["1.2.3", "unknown-tag", "v1-2-3"], + }, + "ver$major-$minor-$patch$prerelease": { + "tags_per_version": [ + ("1.2.3", "ver1-2-3"), + ("1.2.3a0", "ver1-2-3a0"), + ("1.2.3+1.0.0", "ver1-2-3"), + ], + "invalid_tags": ["1.2.3", "unknown-tag", "v1-2-3", "v1.0.0", "ver1.0.0+123"], + }, + "ver$major.$minor.$patch$prerelease-majestic": { + "tags_per_version": [ + ("1.2.3rc2", "ver1.2.3rc2-majestic"), + ], + "invalid_tags": ["1.2.3", "unknown-tag", "v1-2-3", "v1.0.0", "ver1.0.0"], + }, + "v$version-local": { + "tags_per_version": [("1.2.3+1.0.0", "v1.2.3+1.0.0-local")], + "invalid_tags": ["1.2.3", "unknown-tag", "v1-2-3", "v1.0.0", "ver1.0.0"], + }, +} + + +@pytest.mark.parametrize( + "tag_format, version, expected_tag_name", + [ + (tag_format, version, expected_tag_name) + for tag_format, format_dict in TAG_FORMATS.items() + for version, expected_tag_name in format_dict["tags_per_version"] + ], +) +def test_tag_from_version(tag_format, version, expected_tag_name): + new_tag = tag_from_version(Version(version), tag_format) + assert new_tag == expected_tag_name + + +@pytest.mark.parametrize( + "tag_format,tag_name", + [ + (tag_format, tag_name) + for tag_format, format_dict in TAG_FORMATS.items() + for _, tag_name in format_dict["tags_per_version"] + ], +) +def test_make_tag_pattern_matches(tag_format: str, tag_name: str): + pattern = re.compile(make_tag_pattern(tag_format=tag_format), flags=re.VERBOSE) + assert pattern.fullmatch(tag_name) + + +@pytest.mark.parametrize( + "tag_format,invalid_tag_name", + [ + (tag_format, invalid_tag_name) + for tag_format, format_dict in TAG_FORMATS.items() + for invalid_tag_name in format_dict["invalid_tags"] + ], +) +def test_make_tag_pattern_does_not_match(tag_format: str, invalid_tag_name: str): + pattern = re.compile(make_tag_pattern(tag_format=tag_format), flags=re.VERBOSE) + assert pattern.fullmatch(invalid_tag_name) is None