Skip to content

Commit

Permalink
feat: Introduce tag_regex option with smart default
Browse files Browse the repository at this point in the history
Closes #519

CLI flag name: --tag-regex

Heavily inspired by
#537, but extends
it with a smart default value to exclude non-release tags. This was
suggested in
#519 (comment)
  • Loading branch information
robertschweizer committed Mar 23, 2023
1 parent d22df2d commit 8eea9ea
Show file tree
Hide file tree
Showing 11 changed files with 265 additions and 29 deletions.
2 changes: 0 additions & 2 deletions commitizen/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,6 @@ def generate_tree_from_commits(
"date": current_tag_date,
"changes": changes,
}
# TODO: Check if tag matches the version pattern, otherwise skip it.
# This in order to prevent tags that are not versions.
current_tag_name = commit_tag.name
current_tag_date = commit_tag.date
changes = defaultdict(list)
Expand Down
7 changes: 7 additions & 0 deletions commitizen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,13 @@
"If not set, it will generate changelog from the start"
),
},
{
"name": "--tag-regex",
"help": (
"regex match for tags represented "
"within the changelog. default: '.*'"
),
},
],
},
{
Expand Down
9 changes: 7 additions & 2 deletions commitizen/commands/changelog.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os.path
import re
from difflib import SequenceMatcher
from operator import itemgetter
from typing import Callable, Dict, List, Optional
Expand All @@ -15,7 +16,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:
Expand Down Expand Up @@ -51,6 +52,10 @@ def __init__(self, config: BaseConfig, args):
self.tag_format: str = args.get("tag_format") or self.config.settings.get(
"tag_format", DEFAULT_SETTINGS["tag_format"]
)
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'.
Expand Down Expand Up @@ -124,7 +129,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 = []

Expand Down
7 changes: 5 additions & 2 deletions commitizen/git.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import re
from enum import Enum
from os import linesep
from pathlib import Path
Expand Down Expand Up @@ -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}'
Expand All @@ -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:
Expand Down
23 changes: 22 additions & 1 deletion commitizen/tags.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import re
from string import Template
from typing import Union

from packaging.version import Version
from packaging.version import VERSION_PATTERN, Version


def tag_from_version(version: Union[Version, str], tag_format: str) -> str:
Expand Down Expand Up @@ -29,3 +30,23 @@ def tag_from_version(version: Union[Version, str], tag_format: str) -> str:
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
22 changes: 22 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,28 @@ cz changelog --start-rev="v0.2.0"
changelog_start_rev = "v0.2.0"
```
### `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:
Expand Down
2 changes: 2 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
| `version` | `str` | `None` | Current version. Example: "0.1.2" |
| `version_files` | `list` | `[ ]` | Files were the version will be updated. A pattern to match a line, can also be specified, separated by `:` [See more][version_files] |
| `tag_format` | `str` | `$version` | Format for the git tag, useful for old projects, that use a convention like `"v1.2.1"`. [See more][tag_format] |
| `tag_regex` | `str` | Based on `tag_format` | Tags must match this to be included in the changelog (e.g. `"([0-9.])*"` to exclude pre-releases). [See more][tag_regex] |
| `update_changelog_on_bump` | `bool` | `false` | Create changelog when running `cz bump` |
| `gpg_sign` | `bool` | `false` | Use gpg signed tags instead of lightweight tags. |
| `annotated_tag` | `bool` | `false` | Use annotated tags instead of lightweight tags. [See difference][annotated-tags-vs-lightweight] |
Expand Down Expand Up @@ -114,6 +115,7 @@ commitizen:
[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
Expand Down
18 changes: 18 additions & 0 deletions tests/commands/test_bump_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,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


def test_prevent_prerelease_when_no_increment_detected(
mocker: MockFixture, capsys, tmp_commitizen_project
):
Expand Down
52 changes: 52 additions & 0 deletions tests/commands/test_changelog_command.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import sys
from datetime import datetime
from typing import List
from unittest.mock import patch

import pytest
from pytest_mock import MockFixture
Expand Down Expand Up @@ -968,3 +970,53 @@ def test_empty_commit_list(mocker):
mocker.patch.object(sys, "argv", testargs)
with pytest.raises(NoCommitsFoundError):
cli.main()


@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
59 changes: 57 additions & 2 deletions tests/test_git.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import inspect
import os
import re
import shutil
from typing import List, Optional

import pytest
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


Expand All @@ -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"
Expand All @@ -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):
Expand Down
Loading

0 comments on commit 8eea9ea

Please sign in to comment.