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
7 changes: 7 additions & 0 deletions commitizen/changelog_formats/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import warnings
from importlib import metadata
from typing import TYPE_CHECKING, ClassVar, Protocol

Expand Down Expand Up @@ -99,5 +100,11 @@ def _guess_changelog_format(filename: str | None) -> type[ChangelogFormat] | Non

def __getattr__(name: str) -> Callable[[str], type[ChangelogFormat] | None]:
if name == "guess_changelog_format":
warnings.warn(
"guess_changelog_format is deprecated and will be removed in v5. "
"Use _guess_changelog_format instead.",
DeprecationWarning,
stacklevel=2,
)
return _guess_changelog_format
raise AttributeError(f"module {__name__} has no attribute {name}")
209 changes: 104 additions & 105 deletions commitizen/cli.py

Large diffs are not rendered by default.

21 changes: 9 additions & 12 deletions commitizen/commands/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ def __call__(self) -> None:
)

updated_files: list[str] = []
changelog_file_name = None
dry_run = self.arguments["dry_run"]
if self.changelog_flag:
changelog_args = {
Expand All @@ -318,12 +319,11 @@ def __call__(self) -> None:
"during_version_bump": self.arguments["prerelease"] is None,
}
if self.changelog_to_stdout:
changelog_cmd = Changelog(
self.config,
{**changelog_args, "dry_run": True}, # type: ignore[typeddict-item]
)
try:
changelog_cmd()
Changelog(
self.config,
{**changelog_args, "dry_run": True}, # type: ignore[typeddict-item]
)()
except DryRunExit:
pass

Expand All @@ -332,7 +332,8 @@ def __call__(self) -> None:
{**changelog_args, "file_name": self.file_name}, # type: ignore[typeddict-item]
)
changelog_cmd()
updated_files.append(changelog_cmd.file_name)
changelog_file_name = changelog_cmd.file_name
updated_files.append(changelog_file_name)

# Do not perform operations over files or git.
if dry_run:
Expand Down Expand Up @@ -361,9 +362,7 @@ def __call__(self) -> None:
new_tag_version=new_tag_version,
message=message,
increment=increment,
changelog_file_name=changelog_cmd.file_name
if self.changelog_flag
else None,
changelog_file_name=changelog_file_name,
)

if self.arguments["files_only"]:
Expand Down Expand Up @@ -419,9 +418,7 @@ def __call__(self) -> None:
current_tag_version=new_tag_version,
message=message,
increment=increment,
changelog_file_name=changelog_cmd.file_name
if self.changelog_flag
else None,
changelog_file_name=changelog_file_name,
)

# TODO: For v3 output this only as diagnostic and remove this if
Expand Down
4 changes: 2 additions & 2 deletions commitizen/commands/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class CheckArgs(TypedDict, total=False):
commit_msg: str
rev_range: str
allow_abort: bool
message_length_limit: int | None
message_length_limit: int
allowed_prefixes: list[str]
message: str
use_default_range: bool
Expand All @@ -46,7 +46,7 @@ def __init__(self, config: BaseConfig, arguments: CheckArgs, *args: object) -> N

self.use_default_range = bool(arguments.get("use_default_range"))
self.max_msg_length = arguments.get(
"message_length_limit", config.settings.get("message_length_limit", None)
"message_length_limit", config.settings.get("message_length_limit", 0)
)

# we need to distinguish between None and [], which is a valid value
Expand Down
22 changes: 13 additions & 9 deletions commitizen/commands/commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class CommitArgs(TypedDict, total=False):
dry_run: bool
edit: bool
extra_cli_args: str
message_length_limit: int | None
message_length_limit: int
no_retry: bool
signoff: bool
write_message_to_file: Path | None
Expand Down Expand Up @@ -83,19 +83,23 @@ def _get_message_by_prompt_commit_questions(self) -> str:
raise NoAnswersError()

message = self.cz.message(answers)
if limit := self.arguments.get(
"message_length_limit", self.config.settings.get("message_length_limit", 0)
):
self._validate_subject_length(message=message, length_limit=limit)

self._validate_subject_length(message)
return message

def _validate_subject_length(self, *, message: str, length_limit: int) -> None:
def _validate_subject_length(self, message: str) -> None:
message_length_limit = self.arguments.get(
"message_length_limit", self.config.settings.get("message_length_limit", 0)
)
# By the contract, message_length_limit is set to 0 for no limit
if (
message_length_limit is None or message_length_limit <= 0
): # do nothing for no limit
return

subject = message.partition("\n")[0].strip()
if len(subject) > length_limit:
if len(subject) > message_length_limit:
raise CommitMessageLengthExceededError(
f"Length of commit message exceeds limit ({len(subject)}/{length_limit}), subject: '{subject}'"
f"Length of commit message exceeds limit ({len(subject)}/{message_length_limit}), subject: '{subject}'"
)

def manual_edit(self, message: str) -> str:
Expand Down
24 changes: 22 additions & 2 deletions commitizen/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
from commitizen.config.factory import create_config
from commitizen.cz import registry
from commitizen.defaults import CONFIG_FILES, DEFAULT_SETTINGS
from commitizen.exceptions import InitFailedError, NoAnswersError
from commitizen.exceptions import (
InitFailedError,
MissingCzCustomizeConfigError,
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

Expand Down Expand Up @@ -167,12 +171,28 @@ def _ask_config_path(self) -> Path:
def _ask_name(self) -> str:
name: str = questionary.select(
"Please choose a cz (commit rule): (default: cz_conventional_commits)",
choices=list(registry.keys()),
choices=self._construct_name_choice_with_description(),
default="cz_conventional_commits",
style=self.cz.style,
).unsafe_ask()
return name

def _construct_name_choice_with_description(self) -> list[questionary.Choice]:
choices = []
for cz_name, cz_class in registry.items():
try:
cz_obj = cz_class(self.config)
except MissingCzCustomizeConfigError:
choices.append(questionary.Choice(title=cz_name, value=cz_name))
continue
first_example = cz_obj.schema().partition("\n")[0]
choices.append(
questionary.Choice(
title=cz_name, value=cz_name, description=first_example
)
)
return choices

def _ask_tag(self) -> str:
latest_tag = get_latest_tag_name()
if not latest_tag:
Expand Down
9 changes: 9 additions & 0 deletions commitizen/commands/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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_schemes import get_version_scheme


Expand All @@ -17,6 +18,7 @@ class VersionArgs(TypedDict, total=False):
verbose: bool
major: bool
minor: bool
tag: bool


class Version:
Expand Down Expand Up @@ -59,6 +61,9 @@ def __call__(self) -> None:
version = f"{version_scheme.major}"
elif self.arguments.get("minor"):
version = f"{version_scheme.minor}"
elif self.arguments.get("tag"):
tag_rules = TagRules.from_settings(self.config.settings)
version = tag_rules.normalize_tag(version_scheme)

out.write(
f"Project Version: {version}"
Expand All @@ -73,5 +78,9 @@ def __call__(self) -> None:
)
return

if self.arguments.get("tag"):
out.error("Tag can only be used with --project or --verbose.")
return

# If no arguments are provided, just show the installed commitizen version
out.write(__version__)
38 changes: 17 additions & 21 deletions commitizen/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from .base_config import BaseConfig


def _resolve_config_paths() -> list[Path]:
def _resolve_config_candidates() -> list[BaseConfig]:
git_project_root = git.find_git_project_root()
cfg_search_paths = [Path(".")]

Expand All @@ -18,12 +18,18 @@ def _resolve_config_paths() -> list[Path]:

# The following algorithm is ugly, but we need to ensure that the order of the candidates are preserved before v5.
# Also, the number of possible config files is limited, so the complexity is not a problem.
candidates: list[Path] = []
candidates: list[BaseConfig] = []
for dir in cfg_search_paths:
for filename in defaults.CONFIG_FILES:
out_path = dir / Path(filename)
if out_path.exists() and all(not out_path.samefile(p) for p in candidates):
candidates.append(out_path)
if (
out_path.exists()
and not any(
out_path.samefile(candidate.path) for candidate in candidates
)
and not (conf := _create_config_from_path(out_path)).is_empty_config
):
candidates.append(conf)
return candidates


Expand All @@ -44,21 +50,11 @@ def read_cfg(filepath: str | None = None) -> BaseConfig:
raise ConfigFileIsEmpty()
return conf

config_candidate_paths = _resolve_config_paths()

# Check for multiple config files and warn the user
config_candidates_exclude_pyproject = [
path for path in config_candidate_paths if path.name != "pyproject.toml"
]

for config_candidate_path in config_candidate_paths:
conf = _create_config_from_path(config_candidate_path)
if not conf.is_empty_config:
if len(config_candidates_exclude_pyproject) > 1:
out.warn(
f"Multiple config files detected: {', '.join(map(str, config_candidates_exclude_pyproject))}. "
f"Using config file: '{config_candidate_path}'."
)
return conf
config_candidates = _resolve_config_candidates()
if len(config_candidates) > 1:
out.warn(
f"Multiple config files detected: {', '.join(str(conf.path) for conf in config_candidates)}. "
f"Using config file: '{config_candidates[0].path}'."
)

return BaseConfig()
return config_candidates[0] if config_candidates else BaseConfig()
2 changes: 1 addition & 1 deletion commitizen/cz/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def validate_commit_message(
if any(map(commit_msg.startswith, allowed_prefixes)):
return ValidationResult(True, [])

if max_msg_length is not None:
if max_msg_length is not None and max_msg_length > 0:
msg_len = len(commit_msg.partition("\n")[0].strip())
if msg_len > max_msg_length:
# TODO: capitalize the first letter of the error message for consistency in v5
Expand Down
4 changes: 2 additions & 2 deletions commitizen/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class Settings(TypedDict, total=False):
ignored_tag_formats: Sequence[str]
legacy_tag_formats: Sequence[str]
major_version_zero: bool
message_length_limit: int | None
message_length_limit: int
name: str
post_bump_hooks: list[str] | None
pre_bump_hooks: list[str] | None
Expand Down Expand Up @@ -114,7 +114,7 @@ class Settings(TypedDict, total=False):
"template": None, # default provided by plugin
"extras": {},
"breaking_change_exclamation_in_title": False,
"message_length_limit": None, # None for no limit
"message_length_limit": 0, # 0 for no limit
}

MAJOR = "MAJOR"
Expand Down
21 changes: 20 additions & 1 deletion commitizen/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ def normalize_tag(
version = self.scheme(version) if isinstance(version, str) else version
tag_format = tag_format or self.tag_format

major, minor, patch = version.release
major, minor, patch = (list(version.release) + [0, 0, 0])[:3]
prerelease = version.prerelease or ""

t = Template(tag_format)
Expand All @@ -245,6 +245,25 @@ def find_tag_for(
) -> GitTag | None:
"""Find the first matching tag for a given version."""
version = self.scheme(version) if isinstance(version, str) else version
release = version.release

# 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]] = []
for tag in tags:
try:
tag_version = self.extract_version(tag)
except InvalidVersion:
continue
if tag_version.release[: len(release)] != release:
continue
matching_versions.append((tag_version, tag))

if matching_versions:
_, latest_tag = max(matching_versions, key=lambda vt: vt[0])
return latest_tag

possible_tags = set(self.normalize_tag(version, f) for f in self.tag_formats)
candidates = [t for t in tags if t.name in possible_tags]
if len(candidates) > 1:
Expand Down
13 changes: 13 additions & 0 deletions docs/commands/bump.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ You can also set this in the configuration file with `version_scheme = "semver"`
| Devrelease | `0.1.1.dev1` | `0.1.1-dev1` |
| Dev and pre | `1.0.0a3.dev1` | `1.0.0-a3-dev1` |


!!! note "Incomplete Version Handling"
Commitizen treats a three-part version (major.minor.patch) as complete.
If your configured version is incomplete (for example, `1` or `1.2`), Commitizen pads missing parts with zeros when it needs `major/minor/patch` for tag formatting.
The tag output depends on your `tag_format`: formats using `${version}` keep `1`/`1.2`, while formats using `${major}.${minor}.${patch}` will render `1.0.0`/`1.2.0`.

When bumping from an incomplete version, Commitizen looks for the latest existing tag that matches the provided release prefix.
For example, if the current version is `1.2` and the latest `1.2.x` tag is `1.2.3`, then a patch bump yields `1.2.4` and a minor bump yields `1.3.0`.

!!! tip
To control the behaviour of bumping and version parsing, you may implement your own `version_scheme` by inheriting from `commitizen.version_schemes.BaseVersion` or use an existing plugin package.


### PEP440 Version Examples

Commitizen supports the [PEP 440][pep440] version format, which includes several version types. Here are examples of each:
Expand Down
12 changes: 11 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ select = [
"RUF022",
# unused-noqa
"RUF100",
# flake8-pytest-style
"PT",
# Checks for uses of the assert keyword.
"S101",
# flake8-type-checking (TC)
Expand All @@ -233,7 +235,15 @@ select = [
"TC005",
"TC006",
]
ignore = ["E501", "D1", "D415"]
ignore = [
"E501",
"D1",
"D415",
"PT006", # TODO(bearomorphism): enable this rule
"PT007", # TODO(bearomorphism): enable this rule
"PT011", # TODO(bearomorphism): enable this rule
"PT022", # TODO(bearomorphism): enable this rule
]
extend-safe-fixes = [
"TC", # Move imports inside/outside TYPE_CHECKING blocks
"UP", # Update syntaxes for current Python version recommendations
Expand Down
Loading