diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index 5521da373..6bafa5912 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -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 self.cz = factory.committer_factory(self.config) @@ -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 = ( diff --git a/commitizen/cz/customize/customize.py b/commitizen/cz/customize/customize.py index 8fcc63fac..ab2bc5638 100644 --- a/commitizen/cz/customize/customize.py +++ b/commitizen/cz/customize/customize.py @@ -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 @@ -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 @@ -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] + return result def message(self, answers: Mapping[str, Any]) -> str: message_template = Template(self.custom_settings.get("message_template", "")) diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 4865ccc18..1895c26e4 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -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 @@ -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, diff --git a/commitizen/question.py b/commitizen/question.py index 043b8f3ba..d397d9856 100644 --- a/commitizen/question.py +++ b/commitizen/question.py @@ -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): diff --git a/commitizen/tags.py b/commitizen/tags.py index b3bcbe7a0..de3f02acc 100644 --- a/commitizen/tags.py +++ b/commitizen/tags.py @@ -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]: @@ -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: """ @@ -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: diff --git a/docs/commands/changelog.md b/docs/commands/changelog.md index 8b7a7a4d4..c5005f2ee 100644 --- a/docs/commands/changelog.md +++ b/docs/commands/changelog.md @@ -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 @@ -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. @@ -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. diff --git a/docs/customization/config_file.md b/docs/customization/config_file.md index 50185a758..e0a6b14b0 100644 --- a/docs/customization/config_file.md +++ b/docs/customization/config_file.md @@ -45,6 +45,7 @@ Example: type = "input" name = "message" message = "Body." + required = true [[tool.commitizen.customize.questions]] type = "confirm" @@ -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. | diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index b2d024ac7..2f8166da0 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -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, diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 11c3a6044..1771be5a2 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -1191,6 +1191,41 @@ def test_generate_tree_from_commits_with_no_commits(tags): assert tuple(tree) == ({"changes": {}, "date": "", "version": "Unreleased"},) +def test_generate_tree_from_commits_skip_prereleases(gitcommits, tags): + """skip_prereleases=True must omit all prerelease entries from the tree.""" + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.bump_pattern + rules = changelog.TagRules(skip_prereleases=True) + tree = list( + changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern, rules=rules + ) + ) + versions = [entry["version"] for entry in tree] + # Prerelease tags from TAGS: "1.0.0b2", "v1.0.0b1" + assert "1.0.0b2" not in versions + assert "v1.0.0b1" not in versions + # Stable releases must still be present + assert "v1.2.0" in versions + assert "v1.0.0" in versions + + +def test_skip_prereleases_wins_over_merge_prereleases(gitcommits, tags): + """When both skip_prereleases and merge_prereleases are set, skip wins.""" + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.bump_pattern + rules = changelog.TagRules(skip_prereleases=True, merge_prereleases=True) + tree = list( + changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern, rules=rules + ) + ) + versions = [entry["version"] for entry in tree] + # Prereleases must be absent even though merge_prereleases=True + assert "1.0.0b2" not in versions + assert "v1.0.0b1" not in versions + + @pytest.mark.parametrize( ("change_type_order", "expected_reordering"), [ @@ -1651,35 +1686,69 @@ def test_tags_rules_get_version_tags(capsys: pytest.CaptureFixture): @pytest.mark.usefixtures("in_repo_root") -def test_changelog_file_name_from_args_and_config(): +@pytest.mark.parametrize( + ("args_file_name", "config_file_name", "config_path", "expected"), + [ + # CLI-provided --file-name: must be used as-is (relative to cwd). + # The config-dir anchor must NOT be applied. + ( + "CUSTOM.md", + "CHANGELOG.md", + Path("/my/project/pyproject.toml"), + "CUSTOM.md", + ), + # Config-provided changelog_file: must be resolved relative to the + # config file's parent directory, not the cwd. + ( + None, + "CHANGELOG.md", + Path("/my/project/pyproject.toml"), + Path("/my/project/CHANGELOG.md").as_posix(), + ), + # Nested config-provided path: still resolved relative to config dir. + ( + None, + "docs/CHANGELOG.md", + Path("/my/project/pyproject.toml"), + Path("/my/project/docs/CHANGELOG.md").as_posix(), + ), + # CLI-provided nested path: used as-is (relative to cwd). + ( + "changelog/CHANGES.rst", + "CHANGELOG.md", + Path("/my/project/pyproject.toml"), + "changelog/CHANGES.rst", + ), + ], +) +def test_changelog_file_name_resolution( + args_file_name: str | None, + config_file_name: str, + config_path: Path, + expected: str, +): + """CLI --file-name is relative to cwd; config changelog_file is relative to + the config file's directory. Fixes https://github.com/commitizen-tools/commitizen/issues/1411 + """ mock_config = Mock(spec=BaseConfig) mock_path = Mock(spec=Path) - mock_path.parent = Path("/my/project") + mock_path.parent = config_path.parent mock_config.path = mock_path mock_config.settings = { "name": "cz_conventional_commits", - "changelog_file": "CHANGELOG.md", + "changelog_file": config_file_name, "encoding": "utf-8", - "changelog_start_rev": "v1.0.0", + "changelog_start_rev": None, "tag_format": "$version", "legacy_tag_formats": [], "ignored_tag_formats": [], - "incremental": True, - "changelog_merge_prerelease": True, + "changelog_incremental": False, + "changelog_merge_prerelease": False, } - args = { - "file_name": "CUSTOM.md", - "unreleased_version": "1.0.1", - } - changelog = Changelog(mock_config, args) - assert ( - Path(changelog.file_name).resolve() == Path("/my/project/CUSTOM.md").resolve() - ) + args: dict = {"unreleased_version": "1.0.1"} + if args_file_name is not None: + args["file_name"] = args_file_name - args = {"unreleased_version": "1.0.1"} - changelog = Changelog(mock_config, args) - assert ( - Path(changelog.file_name).resolve() - == Path("/my/project/CHANGELOG.md").resolve() - ) + cl = Changelog(mock_config, args) # type: ignore[arg-type] + assert cl.file_name == expected diff --git a/tests/test_conf.py b/tests/test_conf.py index c004e96e1..fbc5f3ca4 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -100,6 +100,7 @@ "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, @@ -140,6 +141,7 @@ "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, diff --git a/tests/test_cz_customize.py b/tests/test_cz_customize.py index 311eea19a..e6ff5d1ef 100644 --- a/tests/test_cz_customize.py +++ b/tests/test_cz_customize.py @@ -609,3 +609,105 @@ def test_change_type_map(config): def test_change_type_map_unicode(config_with_unicode): cz = CustomizeCommitsCz(config_with_unicode) assert cz.change_type_map == {"✨ feature": "Feat", "🐛 bug fix": "Fix"} + + +# --------------------------------------------------------------------------- +# Tests for `required` field on input questions +# --------------------------------------------------------------------------- + +TOML_WITH_REQUIRED = r""" + [tool.commitizen.customize] + message_template = "{{message}}" + + [[tool.commitizen.customize.questions]] + type = "input" + name = "message" + message = "Body." + required = true + + [[tool.commitizen.customize.questions]] + type = "input" + name = "optional_note" + message = "Optional note." +""" + +TOML_WITHOUT_REQUIRED = r""" + [tool.commitizen.customize] + message_template = "{{message}}" + + [[tool.commitizen.customize.questions]] + type = "input" + name = "message" + message = "Body." +""" + + +def test_required_question_has_validate_callable(): + """A question with required=true should expose a validate callable.""" + config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + questions = cz.questions() + + required_q = questions[0] + assert "validate" in required_q + assert callable(required_q["validate"]) + + +def test_required_field_is_removed_from_question_dict(): + """The `required` key must not be forwarded to questionary.""" + config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + for q in cz.questions(): + assert "required" not in q + + +def test_required_validator_rejects_empty_input(): + """Validator must reject empty and whitespace-only strings.""" + config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + validate = cz.questions()[0]["validate"] + + assert validate("") is not True + assert validate(" ") is not True + assert isinstance(validate(""), str) # error message + + +def test_required_validator_accepts_nonempty_input(): + """Validator must accept any non-whitespace-only input.""" + config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + validate = cz.questions()[0]["validate"] + + assert validate("hello") is True + assert validate(" hi ") is True + + +def test_optional_question_has_no_validate(): + """Questions without required=true must not get a validate callable.""" + config = TomlConfig(data=TOML_WITHOUT_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + assert "validate" not in cz.questions()[0] + + +def test_optional_question_in_mixed_config(): + """Only questions with required=true receive a validator; others are unaffected.""" + config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + questions = cz.questions() + + required_q = questions[0] + optional_q = questions[1] + + assert "validate" in required_q + assert "validate" not in optional_q + + +def test_required_does_not_mutate_config_settings(): + """Processing questions must not mutate the underlying config data.""" + config = TomlConfig(data=TOML_WITH_REQUIRED, path=Path("not_exist.toml")) + cz = CustomizeCommitsCz(config) + # Call questions() twice; the second call must still work correctly. + cz.questions() + questions = cz.questions() + assert "required" not in questions[0] + assert "validate" in questions[0]