diff --git a/AGENTS.md b/AGENTS.md index 2e08e188a..3eeb2cf5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,8 +76,38 @@ Always run at least `uv run ruff check --fix . && uv run ruff format .` before p - **Types**: Preserve or improve existing type hints. - **Errors**: Prefer `commitizen/exceptions.py` error types; keep messages clear for CLI users. - **Output**: Use `commitizen/out.py`; do not add noisy logging. +- **Testing**: Follow the Arrange, Act, Assert (AAA) pattern. Visually separate these phases with blank lines or comments. ## When Unsure - Prefer **reading tests and documentation first** to understand the expected behavior. - When behavior is ambiguous, **assume backward compatibility** with current tests and docs is required. + +## Documentation Guidelines + +- 100% Coverage: Every new module, class, method, and function MUST have a docstring. No exceptions +- Even simple functions or internal helpers require documentation to explain their context + +### Docstring Format: Modified Google Style + +Use Google Docstring Style with these major modifications: + +1. **NEVER include type hints in the docstring.** We rely exclusively on Python's PEP 484 type signatures. +2. **Classes MUST include an example.** Any class documentation must contain a brief usage example formatted in Markdown. +3. **Class Attributes MUST be documented.** The class docstring must document the instance variables initialized in `__init__` within an `Attributes:` section. + +**Format Rules:** + +- `Args:`, `Returns:`, and `Attributes:` sections must describe the _semantic meaning_ and _constraints_ of the variables, not their types. +- Omit the type in the lists (e.g., use `user_id: The ID of the user`, NOT `user_id (int): The ID of the user`). + +### Content Focus: The "What" and "Why" + +Code explains _how_. Your docstrings must explain _what_ and _why_. Every complex function or class MUST include a **Considerations** section if architectural choices were made. + +When documenting, you must include: + +1. **The Core Intent:** What business or technical rule is this solving? +2. **Notes / Why:** Why was this approach chosen over the obvious alternative? +3. **Discarded Approaches:** If a simpler method wasn't used (e.g., avoiding an ORM feature for raw SQL, or caching strategies), explain what was discarded and why. +4. **Edge Cases & Gotchas:** What weird scenarios does this code handle? diff --git a/commitizen/cli.py b/commitizen/cli.py index 177099480..40fdbd159 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -583,7 +583,6 @@ def __call__( "const": "USE_GIT_COMMITS", "choices": ["USE_GIT_COMMITS"] + [str(increment) for increment in VersionIncrement], - "exclusive_group": "group2", }, { "name": "manual_version", diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index 5521da373..7dbc28ac5 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -31,7 +31,7 @@ class ChangelogArgs(TypedDict, total=False): change_type_order: list[str] current_version: str dry_run: bool - file_name: str + file_name: str | None incremental: bool merge_prerelease: bool rev_range: str diff --git a/commitizen/commands/version.py b/commitizen/commands/version.py index 2a4bcea88..e3e2c0c14 100644 --- a/commitizen/commands/version.py +++ b/commitizen/commands/version.py @@ -4,14 +4,19 @@ from packaging.version import InvalidVersion -from commitizen import out +from commitizen import bump, factory, git, out from commitizen.__version__ import __version__ from commitizen.config import BaseConfig -from commitizen.exceptions import NoVersionSpecifiedError, VersionSchemeUnknown +from commitizen.exceptions import ( + NoCommitsFoundError, + NoPatternMapError, + NoVersionSpecifiedError, + VersionSchemeUnknown, +) from commitizen.providers import get_provider from commitizen.tags import TagRules from commitizen.version_increment import VersionIncrement -from commitizen.version_schemes import Increment, get_version_scheme +from commitizen.version_schemes import Increment, VersionProtocol, get_version_scheme class VersionArgs(TypedDict, total=False): @@ -42,6 +47,7 @@ class Version: def __init__(self, config: BaseConfig, arguments: VersionArgs) -> None: self.config: BaseConfig = config self.arguments = arguments + self.cz = factory.committer_factory(self.config) def __call__(self) -> None: if self.arguments.get("report"): @@ -87,8 +93,7 @@ def __call__(self) -> None: # TODO: implement USE_GIT_COMMITS by deriving the increment from # git history. This requires refactoring the bump logic out of # `commitizen/commands/bump.py` so it can be reused here. See #1678. - out.error("--next USE_GIT_COMMITS is not implemented yet.") - return + version = self._get_next_git_version(version) next_increment = VersionIncrement.from_value(next_increment_str) increment: Increment | None @@ -100,6 +105,9 @@ def __call__(self) -> None: increment = "MINOR" else: increment = "MAJOR" + + # TODO: Consider adding all the parameters `.bump` supports: + # prerelease, prerelease_offset,exact_increment, etc... version = version.bump(increment=increment) if self.arguments.get("major"): @@ -139,3 +147,39 @@ def __call__(self) -> None: # If no arguments are provided, just show the installed commitizen version out.write(__version__) + + def _get_next_git_version( + self, current_version: VersionProtocol + ) -> VersionProtocol: + """Calculate the next version based on commits.""" + rules = TagRules.from_settings(self.config.settings) + current_tag = rules.find_tag_for(git.get_tags(), current_version) + commits = git.get_commits(current_tag.name if current_tag else None) + + # No commits, there is no need to create an empty tag. + # Unless we previously had a prerelease. + if not commits and not current_version.is_prerelease: + raise NoCommitsFoundError("[NO_COMMITS_FOUND]\nNo new commits found.") + + bump_map = ( + self.cz.bump_map_major_version_zero + if self.config.settings["major_version_zero"] + else self.cz.bump_map + ) + bump_pattern = self.cz.bump_pattern + + if not bump_map or not bump_pattern: + raise NoPatternMapError( + f"'{self.config.settings['name']}' rule does not support bump" + ) + increment = bump.find_increment( + commits, regex=bump_pattern, increments_map=bump_map + ) + + # TODO: Consider adding all the parameters `.bump` supports: + # prerelease, prerelease_offset,exact_increment, etc.. + new_version = current_version.bump( + increment, + ) + + return new_version diff --git a/docs/commands/version.md b/docs/commands/version.md index 198df5b48..bce9baef6 100644 --- a/docs/commands/version.md +++ b/docs/commands/version.md @@ -13,10 +13,9 @@ Get the version of the installed Commitizen or the current project (default: ins - **`--major`**, **`--minor`**, **`--patch`**: print only that component of the (possibly manual) project version. Requires `--project`, `--verbose`, or a manual version. - **`--next` `[MAJOR|MINOR|PATCH|NONE]`**: print the version after applying that bump to the current project or manual version. `NONE` leaves the version unchanged. +- **`--next USE_GIT_COMMITS`**: derive the bump automatically from the commits since the tag matching the current version, using your commit rules' bump map (the same logic as `cz bump`). Passing `--next` with no value defaults to `USE_GIT_COMMITS`. If no tag matches the current version, all commits are considered. When there are no new commits (and the current version is not a pre-release), the command errors out. The functionality does not yet include advanced versioning like prereleases or exact_increment. - **`--tag`**: print the version formatted with your `tag_format` (requires `--project` or `--verbose`). -`--next USE_GIT_COMMITS` is reserved for a future feature (derive the bump from git history) and is not implemented yet. - ## Examples ```bash @@ -24,4 +23,15 @@ cz version --project cz version 2.0.0 --next MAJOR cz version --project --major cz version --verbose + +# Derive the next version from the commits since the current version's tag +cz version --project --next USE_GIT_COMMITS +# Equivalent shorthand (--next defaults to USE_GIT_COMMITS) +cz version --project --next +# Retrieve just the next major part of the version +cz version --project --next --major +# Works with a manual version too +cz version 1.4.0 --next +# Combine with --tag to format the derived version using your tag_format +cz version --project --next --tag ``` diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_version_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_version_.txt index 824f6e9fc..890dd5362 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_version_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_version_.txt @@ -1,5 +1,6 @@ usage: cz version [-h] [-r | -p | -c | -v] - [--major | --minor | --tag | --patch | --next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]] + [--major | --minor | --tag | --patch] + [--next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]] [MANUAL_VERSION] Get the version of the installed commitizen or the current project (default: diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_version_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_version_.txt index 824f6e9fc..890dd5362 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_version_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_version_.txt @@ -1,5 +1,6 @@ usage: cz version [-h] [-r | -p | -c | -v] - [--major | --minor | --tag | --patch | --next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]] + [--major | --minor | --tag | --patch] + [--next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]] [MANUAL_VERSION] Get the version of the installed commitizen or the current project (default: diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_version_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_version_.txt index 824f6e9fc..890dd5362 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_version_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_version_.txt @@ -1,5 +1,6 @@ usage: cz version [-h] [-r | -p | -c | -v] - [--major | --minor | --tag | --patch | --next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]] + [--major | --minor | --tag | --patch] + [--next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]] [MANUAL_VERSION] Get the version of the installed commitizen or the current project (default: diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_version_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_version_.txt index 51c985679..2450ce0b5 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_version_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_version_.txt @@ -1,5 +1,5 @@ usage: cz version [-h] [-r | -p | -c | -v] [--major | --minor | --tag | - --patch | --next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]] + --patch] [--next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]] [MANUAL_VERSION] Get the version of the installed commitizen or the current project (default: diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_version_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_version_.txt index 51c985679..2450ce0b5 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_version_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_version_.txt @@ -1,5 +1,5 @@ usage: cz version [-h] [-r | -p | -c | -v] [--major | --minor | --tag | - --patch | --next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]] + --patch] [--next [{USE_GIT_COMMITS,NONE,PATCH,MINOR,MAJOR}]] [MANUAL_VERSION] Get the version of the installed commitizen or the current project (default: diff --git a/tests/commands/test_version_command.py b/tests/commands/test_version_command.py index 099e7110e..ac4bf54e7 100644 --- a/tests/commands/test_version_command.py +++ b/tests/commands/test_version_command.py @@ -7,6 +7,9 @@ from commitizen import commands from commitizen.__version__ import __version__ from commitizen.config.base_config import BaseConfig +from commitizen.cz.base import BaseCommitizen +from commitizen.exceptions import NoCommitsFoundError, NoPatternMapError +from tests.utils import UtilFixture def test_version_for_showing_project_version_error(config, capsys): @@ -282,14 +285,172 @@ def test_version_unknown_scheme(config, capsys): assert "Unknown version scheme." in captured.err -def test_version_use_git_commits_not_implemented(config, capsys): +@pytest.mark.parametrize( + ("commit_message", "expected_version"), + [ + ("feat: new feature", "1.1.0"), + ("fix: a bug", "1.0.1"), + ("feat!: breaking change", "2.0.0"), + ("docs: update readme", "1.0.0"), + ], +) +@pytest.mark.usefixtures("tmp_git_project") +def test_version_next_use_git_commits( + config: BaseConfig, + capsys: pytest.CaptureFixture, + util: UtilFixture, + commit_message: str, + expected_version: str, +): + """USE_GIT_COMMITS derives the next version from commits since the last tag.""" config.settings["version"] = "1.0.0" + util.create_file_and_commit("feat: initial commit") + util.create_tag("1.0.0") + util.create_file_and_commit(commit_message) + commands.Version( config, {"project": True, "next": "USE_GIT_COMMITS"}, )() + captured = capsys.readouterr() - assert "USE_GIT_COMMITS is not implemented" in captured.err + assert captured.out == f"{expected_version}\n" + + +@pytest.mark.usefixtures("tmp_git_project") +def test_version_next_use_git_commits_manual_version( + config: BaseConfig, capsys: pytest.CaptureFixture, util: UtilFixture +): + """USE_GIT_COMMITS also works with an explicit MANUAL_VERSION.""" + util.create_file_and_commit("feat: initial commit") + util.create_tag("1.0.0") + util.create_file_and_commit("feat: new feature") + + commands.Version( + config, + {"manual_version": "1.0.0", "next": "USE_GIT_COMMITS"}, + )() + + captured = capsys.readouterr() + assert captured.out == "1.1.0\n" + + +@pytest.mark.usefixtures("tmp_git_project") +def test_version_next_use_git_commits_without_matching_tag( + config: BaseConfig, capsys: pytest.CaptureFixture, util: UtilFixture +): + """When no tag matches the current version, all commits are considered.""" + config.settings["version"] = "2.0.0" + util.create_file_and_commit("feat: initial commit") + util.create_file_and_commit("feat: new feature") + + commands.Version( + config, + {"project": True, "next": "USE_GIT_COMMITS"}, + )() + + captured = capsys.readouterr() + assert captured.out == "2.1.0\n" + + +@pytest.mark.usefixtures("tmp_git_project") +def test_version_next_use_git_commits_major_version_zero( + config: BaseConfig, capsys: pytest.CaptureFixture, util: UtilFixture +): + """major_version_zero uses the zero bump map so no major bump is emitted.""" + config.settings["version"] = "0.1.0" + config.settings["major_version_zero"] = True + util.create_file_and_commit("feat: initial commit") + util.create_tag("0.1.0") + util.create_file_and_commit("feat!: breaking change") + + commands.Version( + config, + {"project": True, "next": "USE_GIT_COMMITS"}, + )() + + captured = capsys.readouterr() + assert captured.out == "0.2.0\n" + + +@pytest.mark.usefixtures("tmp_git_project") +def test_version_next_use_git_commits_prerelease_without_commits( + config: BaseConfig, capsys: pytest.CaptureFixture, util: UtilFixture +): + """A prerelease with no new commits finalizes into its release version.""" + config.settings["version"] = "1.0.0rc1" + util.create_file_and_commit("feat: initial commit") + util.create_tag("1.0.0rc1") + + commands.Version( + config, + {"project": True, "next": "USE_GIT_COMMITS"}, + )() + + captured = capsys.readouterr() + assert captured.out == "1.0.0\n" + + +@pytest.mark.usefixtures("tmp_git_project") +def test_version_next_use_git_commits_no_commits_raises( + config: BaseConfig, util: UtilFixture +): + """No new commits since the last (non-prerelease) tag raises an error.""" + config.settings["version"] = "1.0.0" + util.create_file_and_commit("feat: initial commit") + util.create_tag("1.0.0") + + with pytest.raises(NoCommitsFoundError): + commands.Version( + config, + {"project": True, "next": "USE_GIT_COMMITS"}, + )() + + +class _NoBumpRulesCz(BaseCommitizen): + """A commitizen rule without bump pattern or map to trigger NoPatternMapError.""" + + bump_pattern = None + bump_map = None + + def questions(self): + return [] + + def message(self, answers): + return "" + + def example(self) -> str: + return "" + + def schema(self) -> str: + return "" + + def schema_pattern(self) -> str: + return "" + + def info(self) -> str: + return "" + + +@pytest.mark.usefixtures("tmp_git_project") +def test_version_next_use_git_commits_no_pattern_map_raises( + config: BaseConfig, util: UtilFixture, mocker: MockerFixture +): + """A rule that does not support bumping raises NoPatternMapError.""" + config.settings["version"] = "1.0.0" + mocker.patch( + "commitizen.factory.committer_factory", + return_value=_NoBumpRulesCz(config), + ) + util.create_file_and_commit("feat: initial commit") + util.create_tag("1.0.0") + util.create_file_and_commit("feat: new feature") + + with pytest.raises(NoPatternMapError): + commands.Version( + config, + {"project": True, "next": "USE_GIT_COMMITS"}, + )() def test_version_no_arguments_shows_commitizen_version(config, capsys):