Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 3 additions & 3 deletions commitizen/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
if TYPE_CHECKING:
from collections.abc import Generator, Iterable

from commitizen.version_schemes import Increment, Version
from commitizen.version_schemes import Increment, VersionProtocol

VERSION_TYPES = [None, PATCH, MINOR, MAJOR]

Expand Down Expand Up @@ -131,8 +131,8 @@ def _resolve_files_and_regexes(


def create_commit_message(
current_version: Version | str,
new_version: Version | str,
current_version: VersionProtocol | str,
new_version: VersionProtocol | str,
message_template: str | None = None,
) -> str:
if message_template is None:
Expand Down
35 changes: 33 additions & 2 deletions commitizen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
InvalidCommandArgumentError,
NoCommandFoundError,
)
from commitizen.version_increment import VersionIncrement

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -542,13 +543,19 @@ def __call__(
},
{
"name": ["--major"],
"help": "Output just the major version. Must be used with --project or --verbose.",
"help": (
"Output just the major version. Must be used with MANUAL_VERSION, "
"--project, or --verbose."
),
"action": "store_true",
"exclusive_group": "group2",
},
{
"name": ["--minor"],
"help": "Output just the minor version. Must be used with --project or --verbose.",
"help": (
"Output just the minor version. Must be used with MANUAL_VERSION, "
"--project, or --verbose."
),
"action": "store_true",
"exclusive_group": "group2",
},
Expand All @@ -558,6 +565,30 @@ def __call__(
"action": "store_true",
"exclusive_group": "group2",
},
{
"name": ["--patch"],
"help": "Output the patch version only. Need to be used with MANUAL_VERSION, --project or --verbose.",
"action": "store_true",
"exclusive_group": "group2",
Comment thread
bearomorphism marked this conversation as resolved.
},
{
"name": ["--next"],
"help": "Output the next version.",
"type": str,
"nargs": "?",
"default": None,
"const": "USE_GIT_COMMITS",
"choices": ["USE_GIT_COMMITS"]
+ [str(increment) for increment in VersionIncrement],
Comment thread
bearomorphism marked this conversation as resolved.
"exclusive_group": "group2",
},
{
"name": "manual_version",
"type": str,
"nargs": "?",
"help": "Use the version provided instead of the version from the project. Can be used to test the selected version scheme.",
"metavar": "MANUAL_VERSION",
},
],
},
],
Expand Down
10 changes: 7 additions & 3 deletions commitizen/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
NoAnswersError,
)
from commitizen.git import get_latest_tag_name, get_tag_names, smart_open
from commitizen.version_schemes import KNOWN_SCHEMES, Version, get_version_scheme
from commitizen.version_schemes import (
KNOWN_SCHEMES,
VersionProtocol,
get_version_scheme,
)

if TYPE_CHECKING:
from commitizen.config import (
Expand Down Expand Up @@ -265,7 +269,7 @@ def _ask_version_scheme(self) -> str:
).unsafe_ask()
return scheme

def _ask_major_version_zero(self, version: Version) -> bool:
def _ask_major_version_zero(self, version: VersionProtocol) -> bool:
"""Ask for setting: major_version_zero"""
if version.major > 0:
return False
Expand Down Expand Up @@ -323,7 +327,7 @@ def _write_config_to_file(
cz_name: str,
version_provider: str,
version_scheme: str,
version: Version,
version: VersionProtocol,
tag_format: str,
update_changelog_on_bump: bool,
major_version_zero: bool,
Expand Down
84 changes: 64 additions & 20 deletions commitizen/commands/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,32 @@
import sys
from typing import TypedDict

from packaging.version import InvalidVersion

from commitizen import out
from commitizen.__version__ import __version__
from commitizen.config import BaseConfig
from commitizen.exceptions import NoVersionSpecifiedError, VersionSchemeUnknown
from commitizen.providers import get_provider
from commitizen.tags import TagRules
from commitizen.version_increment import VersionIncrement
from commitizen.version_schemes import get_version_scheme


class VersionArgs(TypedDict, total=False):
manual_version: str | None
next: str | None

# Exclusive groups 1
commitizen: bool
report: bool
project: bool
verbose: bool

# Exclusive groups 2
major: bool
minor: bool
patch: bool
tag: bool


Expand All @@ -43,40 +53,74 @@ def __call__(self) -> None:
if self.arguments.get("verbose"):
out.write(f"Installed Commitizen Version: {__version__}")

if not self.arguments.get("commitizen") and (
self.arguments.get("project") or self.arguments.get("verbose")
if self.arguments.get("commitizen"):
out.write(__version__)
return

if (
self.arguments.get("project")
or self.arguments.get("verbose")
or self.arguments.get("next")
or self.arguments.get("manual_version")
):
version_str = self.arguments.get("manual_version")
if version_str is None:
try:
version_str = get_provider(self.config).get_version()
except NoVersionSpecifiedError:
out.error("No project information in this project.")
return
try:
version = get_provider(self.config).get_version()
except NoVersionSpecifiedError:
out.error("No project information in this project.")
return
try:
version_scheme = get_version_scheme(self.config.settings)(version)
scheme_factory = get_version_scheme(self.config.settings)
except VersionSchemeUnknown:
out.error("Unknown version scheme.")
return

try:
version = scheme_factory(version_str)
except InvalidVersion:
out.error(f"Invalid version: '{version_str}'")
return

if next_increment_str := self.arguments.get("next"):
if next_increment_str == "USE_GIT_COMMITS":
# TODO: implement this
raise NotImplementedError("USE_GIT_COMMITS is not implemented")
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--next USE_GIT_COMMITS currently raises NotImplementedError, which will surface as a stack trace to end users. Please handle this path with a user-facing error (e.g., out.error(...) + return) or remove USE_GIT_COMMITS from the CLI until it’s implemented.

Suggested change
# TODO: implement this
raise NotImplementedError("USE_GIT_COMMITS is not implemented")
out.error("--next USE_GIT_COMMITS is not implemented yet.")
return

Copilot uses AI. Check for mistakes.

next_increment = VersionIncrement.safe_cast(next_increment_str)
# TODO: modify the interface of bump to accept VersionIncrement
version = version.bump(increment=str(next_increment)) # type: ignore[arg-type]
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VersionIncrement.safe_cast() can return NONE, but the code passes "NONE" into version.bump(). bump() is typed/implemented around Increment | None (MAJOR/MINOR/PATCH/None), so this can break custom schemes that validate the increment. Consider mapping VersionIncrement.NONE to None (no bump) and avoiding the type: ignore by passing a valid Increment | None.

Copilot uses AI. Check for mistakes.

if self.arguments.get("major"):
version = f"{version_scheme.major}"
elif self.arguments.get("minor"):
version = f"{version_scheme.minor}"
elif self.arguments.get("tag"):
out.write(version.major)
return
if self.arguments.get("minor"):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize now that this creates problems with the (not)monotonic kind of versions (and possible non-semver). I'm not sure what to do about it.

I think for now it's fine that if you diverge too much from semver in your custom version scheme, then you won't get the full range of features.

out.write(version.minor)
return
if self.arguments.get("patch"):
out.write(version.micro)
return
Comment on lines +107 to +110
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new --patch output path isn’t covered by tests (there are tests for --major/--minor, but none for --patch). Adding a simple assertion for the patch component would protect this behavior from regressions.

Copilot uses AI. Check for mistakes.

display_version: str
if self.arguments.get("tag"):
tag_rules = TagRules.from_settings(self.config.settings)
version = tag_rules.normalize_tag(version_scheme)
display_version = tag_rules.normalize_tag(version)
else:
display_version = str(version)

out.write(
f"Project Version: {version}"
f"Project Version: {display_version}"
if self.arguments.get("verbose")
else version
else display_version
)
return

if self.arguments.get("major") or self.arguments.get("minor"):
out.error(
"Major or minor version can only be used with --project or --verbose."
)
return
for argument in ("major", "minor", "patch"):
if self.arguments.get(argument):
out.error(
f"{argument} can only be used with MANUAL_VERSION, --project or --verbose."
)
return

if self.arguments.get("tag"):
out.error("Tag can only be used with --project or --verbose.")
Expand Down
22 changes: 11 additions & 11 deletions commitizen/out.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,35 @@
sys.stdout.reconfigure(encoding="utf-8")


def write(value: str, *args: object) -> None:
def write(value: object, *args: object) -> None:
"""Intended to be used when value is multiline."""
print(value, *args)


def line(value: str, *args: object, **kwargs: Any) -> None:
def line(value: object, *args: object, **kwargs: Any) -> None:
"""Wrapper in case I want to do something different later."""
print(value, *args, **kwargs)


def error(value: str) -> None:
message = colored(value, "red")
def error(value: object) -> None:
message = colored(str(value), "red")
line(message, file=sys.stderr)


def success(value: str) -> None:
message = colored(value, "green")
def success(value: object) -> None:
message = colored(str(value), "green")
line(message)


def info(value: str) -> None:
message = colored(value, "blue")
def info(value: object) -> None:
message = colored(str(value), "blue")
line(message)


def diagnostic(value: str) -> None:
def diagnostic(value: object) -> None:
line(value, file=sys.stderr)


def warn(value: str) -> None:
message = colored(value, "magenta")
def warn(value: object) -> None:
message = colored(str(value), "magenta")
line(message, file=sys.stderr)
17 changes: 7 additions & 10 deletions commitizen/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,14 @@
from commitizen.version_schemes import (
DEFAULT_SCHEME,
InvalidVersion,
Version,
VersionScheme,
VersionProtocol,
get_version_scheme,
)

if TYPE_CHECKING:
import sys
from collections.abc import Iterable, Sequence

from commitizen.version_schemes import VersionScheme

# Self is Python 3.11+ but backported in typing-extensions
if sys.version_info < (3, 11):
from typing_extensions import Self
Expand Down Expand Up @@ -75,15 +72,15 @@ class TagRules:
assert not rules.is_version_tag("warn1.0.0", warn=True) # Does warn

assert rules.search_version("# My v1.0.0 version").version == "1.0.0"
assert rules.extract_version("v1.0.0") == Version("1.0.0")
assert rules.extract_version("v1.0.0") == VersionProtocol("1.0.0")
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example in the docstring uses VersionProtocol("1.0.0"), but VersionProtocol is a Protocol and not instantiable. This makes the example misleading; it should construct a concrete scheme (e.g., rules.scheme("1.0.0") or Pep440("1.0.0")).

Suggested change
assert rules.extract_version("v1.0.0") == VersionProtocol("1.0.0")
assert rules.extract_version("v1.0.0") == rules.scheme("1.0.0")

Copilot uses AI. Check for mistakes.
try:
assert rules.extract_version("not-a-v1.0.0")
except InvalidVersion:
print("Does not match a tag format")
```
"""

scheme: VersionScheme = DEFAULT_SCHEME
scheme: type[VersionProtocol] = DEFAULT_SCHEME
tag_format: str = DEFAULT_SETTINGS["tag_format"]
legacy_tag_formats: Sequence[str] = field(default_factory=list)
ignored_tag_formats: Sequence[str] = field(default_factory=list)
Expand Down Expand Up @@ -145,7 +142,7 @@ def get_version_tags(
"""Filter in version tags and warn on unexpected tags"""
return [tag for tag in tags if self.is_version_tag(tag, warn)]

def extract_version(self, tag: GitTag) -> Version:
def extract_version(self, tag: GitTag) -> VersionProtocol:
"""
Extract a version from the tag as defined in tag formats.

Expand Down Expand Up @@ -195,7 +192,7 @@ def search_version(self, text: str, last: bool = False) -> VersionTag | None:
return VersionTag(version, match.group(0))

def normalize_tag(
self, version: Version | str, tag_format: str | None = None
self, version: VersionProtocol | str, tag_format: str | None = None
) -> str:
"""
The tag and the software version might be different.
Expand Down Expand Up @@ -225,7 +222,7 @@ def normalize_tag(
)

def find_tag_for(
self, tags: Iterable[GitTag], version: Version | str
self, tags: Iterable[GitTag], version: VersionProtocol | str
) -> GitTag | None:
"""Find the first matching tag for a given version."""
version = self.scheme(version) if isinstance(version, str) else version
Expand All @@ -234,7 +231,7 @@ def find_tag_for(
# If the requested version is incomplete (e.g., "1.2"), try to find the latest
# matching tag that shares the provided prefix.
if len(release) < 3:
matching_versions: list[tuple[Version, GitTag]] = []
matching_versions: list[tuple[VersionProtocol, GitTag]] = []
for tag in tags:
try:
tag_version = self.extract_version(tag)
Expand Down
28 changes: 28 additions & 0 deletions commitizen/version_increment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from enum import IntEnum


class VersionIncrement(IntEnum):
Comment thread
bearomorphism marked this conversation as resolved.
"""An enumeration representing semantic versioning increments.
This class defines the four types of version increments according to semantic versioning:
- NONE: For commits that don't require a version bump (docs, style, etc.)
- PATCH: For backwards-compatible bug fixes
- MINOR: For backwards-compatible functionality additions
- MAJOR: For incompatible API changes
"""

NONE = 0
PATCH = 1
MINOR = 2
MAJOR = 3

def __str__(self) -> str:
return self.name

@classmethod
def safe_cast(cls, value: object) -> "VersionIncrement":
Comment thread
bearomorphism marked this conversation as resolved.
Outdated
if not isinstance(value, str):
return VersionIncrement.NONE
try:
return cls[value]
except KeyError:
return VersionIncrement.NONE
Loading
Loading