Skip to content
Open
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
30 changes: 30 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
1 change: 0 additions & 1 deletion commitizen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion commitizen/commands/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 49 additions & 5 deletions commitizen/commands/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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
Expand All @@ -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"):
Expand Down Expand Up @@ -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
14 changes: 12 additions & 2 deletions docs/commands/version.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,25 @@ 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
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
```
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
165 changes: 163 additions & 2 deletions tests/commands/test_version_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Loading