Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions commitizen/commands/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,32 @@ def __init__(self, config: BaseConfig, arguments: ChangelogArgs) -> None:

self.config = config

changelog_file_name = arguments.get("file_name") or self.config.settings.get(
"changelog_file"
)
# Distinguish the source of the file name so we can apply the correct
# path resolution strategy:
# • CLI-provided (--file-name): use as-is, relative to the current
# working directory — standard CLI convention.
# • Config-provided (changelog_file setting): resolve relative to the
# config file's directory so the path is stable regardless of where
# the user runs `cz` from.
file_name_from_args: str | None = arguments.get("file_name") or None
file_name_from_config: str | None = self.config.settings.get("changelog_file")
changelog_file_name = file_name_from_args or file_name_from_config
if not isinstance(changelog_file_name, str):
raise NotAllowed(
"Changelog file name is broken.\n"
"Check the flag `--file-name` in the terminal "
f"or the setting `changelog_file` in {self.config.path}"
)
self.file_name = (
Path(self.config.path.parent, changelog_file_name).as_posix()
if self.config.path is not None
else changelog_file_name
)
if file_name_from_args:
# Explicit CLI argument — keep relative to cwd (or use as-is if absolute).
self.file_name = file_name_from_args
elif self.config.path is not None:
# From configuration — anchor to the config file's directory.
self.file_name = Path(
self.config.path.parent, changelog_file_name
).as_posix()
else:
self.file_name = changelog_file_name
Comment on lines +65 to +83

self.cz = factory.committer_factory(self.config)

Expand Down Expand Up @@ -114,6 +126,9 @@ def __init__(self, config: BaseConfig, arguments: ChangelogArgs) -> None:
ignored_tag_formats=self.config.settings["ignored_tag_formats"],
merge_prereleases=arguments.get("merge_prerelease")
or self.config.settings["changelog_merge_prerelease"],
skip_prereleases=bool(
self.config.settings.get("changelog_skip_prereleases")
),
)

self.template = (
Expand Down
20 changes: 18 additions & 2 deletions commitizen/cz/customize/customize.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from collections.abc import Mapping
from collections.abc import Iterable, Mapping

from jinja2 import Template

Expand All @@ -23,6 +23,13 @@

__all__ = ["CustomizeCommitsCz"]

_REQUIRED_ANSWER_MSG = "This answer is required."


def _required_validator(val: str) -> bool | str:
"""Return True when *val* is non-blank, otherwise an error message."""
return True if val.strip() else _REQUIRED_ANSWER_MSG


class CustomizeCommitsCz(BaseCommitizen):
bump_pattern = defaults.BUMP_PATTERN
Expand Down Expand Up @@ -50,7 +57,16 @@ def __init__(self, config: BaseConfig) -> None:
setattr(self, attr_name, value)

def questions(self) -> list[CzQuestion]:
return self.custom_settings.get("questions", [{}]) # type: ignore[return-value]
raw_questions: Iterable[CzQuestion] = self.custom_settings.get(
"questions", [{}]
) # type: ignore[assignment]
result: list[CzQuestion] = []
for raw in raw_questions:
q: dict[str, Any] = dict(raw)
if q.get("type") == "input" and q.pop("required", False):
q["validate"] = _required_validator
result.append(q) # type: ignore[arg-type]
Comment on lines +64 to +68
return result

def message(self, answers: Mapping[str, Any]) -> str:
message_template = Template(self.custom_settings.get("message_template", ""))
Expand Down
2 changes: 2 additions & 0 deletions commitizen/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class Settings(TypedDict, total=False):
changelog_format: str | None
changelog_incremental: bool
changelog_merge_prerelease: bool
changelog_skip_prereleases: bool
changelog_start_rev: str | None
customize: CzSettings
encoding: str
Expand Down Expand Up @@ -103,6 +104,7 @@ class Settings(TypedDict, total=False):
"changelog_incremental": False,
"changelog_start_rev": None,
"changelog_merge_prerelease": False,
"changelog_skip_prereleases": False,
"update_changelog_on_bump": False,
"use_shortcuts": False,
"major_version_zero": False,
Expand Down
2 changes: 2 additions & 0 deletions commitizen/question.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class InputQuestion(TypedDict, total=False):
name: str
message: str
filter: Callable[[str], str]
validate: Callable[[str], bool | str]
required: bool


class ConfirmQuestion(TypedDict):
Expand Down
10 changes: 9 additions & 1 deletion commitizen/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class TagRules:
legacy_tag_formats: Sequence[str] = field(default_factory=list)
ignored_tag_formats: Sequence[str] = field(default_factory=list)
merge_prereleases: bool = False
skip_prereleases: bool = False

@property
def tag_formats(self) -> Iterable[str]:
Expand Down Expand Up @@ -166,7 +167,13 @@ def include_in_changelog(self, tag: GitTag) -> bool:
version = self.extract_version(tag)
except InvalidVersion:
return False
return not (self.merge_prereleases and version.is_prerelease)
if version.is_prerelease:
# skip_prereleases wins over merge_prereleases when both are set
if self.skip_prereleases:
return False
if self.merge_prereleases:
return False
return True

def search_version(self, text: str, last: bool = False) -> VersionTag | None:
"""
Expand Down Expand Up @@ -265,6 +272,7 @@ def from_settings(cls, settings: Settings) -> Self:
legacy_tag_formats=settings["legacy_tag_formats"],
ignored_tag_formats=settings["ignored_tag_formats"],
merge_prereleases=settings["changelog_merge_prerelease"],
skip_prereleases=settings["changelog_skip_prereleases"],
)

def _extract_version(self, match: re.Match[str]) -> str:
Expand Down
28 changes: 28 additions & 0 deletions docs/commands/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ Specify the name of the output file. Note that changelog generation only works w
cz changelog --file-name="CHANGES.md"
```

When provided on the command line, the path is interpreted **relative to the current working directory** (standard CLI convention).

This value can be updated in the configuration file with the key `changelog_file` under `tool.commitizen`.

```toml
Expand All @@ -93,6 +95,8 @@ This value can be updated in the configuration file with the key `changelog_file
changelog_file = "CHANGES.md"
```

When set in the configuration file, the path is resolved **relative to the configuration file's directory** (usually the project root). This means the location of the changelog is stable regardless of which directory you run `cz` from.

### `--incremental`

Build from the latest version found in changelog.
Expand Down Expand Up @@ -147,6 +151,30 @@ This flag can be set in the configuration file with the key `changelog_merge_pre
changelog_merge_prerelease = true
```

### `changelog_skip_prereleases`

Omits all prerelease versions (e.g. `rc`, `alpha`, `beta`, `dev`) from the changelog entirely. Stable-release entries are kept, but their commits are attributed only to the stable release — prerelease headers never appear.

This is a configuration-only option (no CLI flag); enable it in your `pyproject.toml`:

```toml
[tool.commitizen]
changelog_skip_prereleases = true
```

With this setting, a history such as:

```
0.1.0-a0 → 0.1.0-b0 → 0.1.0
```

will produce a changelog that shows only `0.1.0`, without `0.1.0-a0` or `0.1.0-b0` entries.

!!! note
When both `changelog_skip_prereleases = true` and `changelog_merge_prerelease = true` are set,
`changelog_skip_prereleases` takes precedence and prerelease entries are dropped rather than
merged.

### `--template`

Provide your own changelog Jinja template by using the `template` settings or the `--template` parameter.
Expand Down
2 changes: 2 additions & 0 deletions docs/customization/config_file.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Example:
type = "input"
name = "message"
message = "Body."
required = true

[[tool.commitizen.customize.questions]]
type = "confirm"
Expand Down Expand Up @@ -181,6 +182,7 @@ Example:
| `default` | `Any` | `None` | (OPTIONAL) The default value for this question. |
| `filter` | `str` | `None` | (OPTIONAL) Validator for user's answer. **(Work in Progress)** |
| `multiline` | `bool` | `False` | (OPTIONAL) Enable multiline support when `type = input`. |
| `required` | `bool` | `False` | (OPTIONAL) When `true` and `type = input`, the user cannot submit an empty answer. An error message ("This answer is required.") is displayed until a non-blank value is entered. |
| `use_search_filter` | `bool` | `False` | (OPTIONAL) Enable search/filter functionality for list/select type questions. This allows users to type and filter through the choices. |
| `use_jk_keys` | `bool` | `True` | (OPTIONAL) Enable/disable j/k keys for navigation in list/select type questions. Set to false if you prefer arrow keys only. |

Expand Down
73 changes: 73 additions & 0 deletions tests/commands/test_changelog_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,79 @@ def test_changelog_config_flag_merge_prerelease(
file_regression.check(out, extension=".md")


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_changelog_config_flag_skip_prereleases(
capsys: pytest.CaptureFixture,
config_path: Path,
util: UtilFixture,
):
"""changelog_skip_prereleases = true must omit prerelease entries from changelog."""
with config_path.open("a", encoding="utf-8") as f:
f.write("changelog_skip_prereleases = true\n")

util.create_file_and_commit("feat: initial feature")
util.run_cli("bump", "--yes")
capsys.readouterr()

util.create_file_and_commit("feat: add new output")
util.create_file_and_commit("fix: output glitch")

# bump to a prerelease
util.run_cli("bump", "--prerelease", "rc", "--yes")
capsys.readouterr()

util.create_file_and_commit("fix: another fix")
util.create_file_and_commit("feat: more stuff")

# bump to stable
util.run_cli("bump", "--yes")
capsys.readouterr()

with pytest.raises(DryRunExit):
util.run_cli("changelog", "--dry-run")

out, _ = capsys.readouterr()
# The prerelease version (e.g. 0.2.0rc1) must not appear
assert "rc" not in out
# Commit messages from stable releases must be present
assert "add new output" in out
assert "more stuff" in out


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_changelog_config_skip_prereleases_wins_over_merge_prerelease(
capsys: pytest.CaptureFixture,
config_path: Path,
util: UtilFixture,
):
"""When both skip_prereleases and merge_prerelease are set, skip wins."""
with config_path.open("a", encoding="utf-8") as f:
f.write("changelog_skip_prereleases = true\n")
f.write("changelog_merge_prerelease = true\n")

util.create_file_and_commit("feat: initial feature")
util.run_cli("bump", "--yes")
capsys.readouterr()

util.create_file_and_commit("feat: add new output")
util.run_cli("bump", "--prerelease", "alpha", "--yes")
capsys.readouterr()

util.create_file_and_commit("fix: another fix")
util.run_cli("bump", "--yes")
capsys.readouterr()

with pytest.raises(DryRunExit):
util.run_cli("changelog", "--dry-run")

out, _ = capsys.readouterr()
# Prerelease entries must not appear
assert "alpha" not in out
# Stable commit messages must be present
assert "add new output" in out
assert "another fix" in out


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_changelog_config_start_rev_option(
capsys: pytest.CaptureFixture,
Expand Down
Loading
Loading