Skip to content
Open
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2626e01
refactor(BaseCommitizen): construct Style object directly to get rid …
bearomorphism Sep 15, 2025
4d1285b
refactor(BaseCommitizen): remove NotImplementedError and make them ab…
bearomorphism Sep 15, 2025
fca28c0
style(UvProvider): fix typo in comment
bearomorphism Sep 16, 2025
5499628
feat: allow `amend!` prefix as created by `git --fixup=reword:<commit>`
lovetheguitar Sep 5, 2025
475f3f2
fix(Init): raise InitFailedError on keyboard interrupt on pre-commit …
bearomorphism Sep 15, 2025
289e83c
test(changelog): remove unused timer fixture
bearomorphism Sep 20, 2025
e30f14e
refactor(cmd): unnest try except
bearomorphism Sep 22, 2025
e7d1084
refactor(ConventionalCommitsCz): rewrite message method to make the p…
bearomorphism Sep 21, 2025
8cacb25
refactor(Bump): remove use of getattr
bearomorphism Sep 20, 2025
8684796
docs: add contributing for nix users
woile Oct 30, 2025
d734348
feat(version): add the ability to just print major or minor version
woile Oct 30, 2025
4f596d8
refactor: remove unnecessary class member tag_format
bearomorphism Sep 19, 2025
e482b40
refactor(utils): make get_backup_file_path to return a path for seman…
bearomorphism Sep 29, 2025
1c60d27
docs(Check): add missing raise exception in __call__
bearomorphism Sep 19, 2025
18ac68b
test(SemVer): refine tests and remove unnecessary tests which test th…
bearomorphism Sep 13, 2025
42acbe4
refactor: remove self.encoding for better maintainability
bearomorphism Sep 19, 2025
3ff4e55
refactor(BaseConfig): update docstring, extract factory method and re…
bearomorphism Sep 14, 2025
6be6197
refactor(Init): make project_info a module and remove self.project_info
bearomorphism Sep 15, 2025
df5a48e
refactor(hooks): refactor to improve readability
bearomorphism Sep 29, 2025
f2c89b5
refactor(Commit): refactor _prompt_commit_questions and fix some type…
bearomorphism Sep 28, 2025
c4902b6
fixup! refactor(Commit): refactor _prompt_commit_questions and fix so…
Lee-W Nov 8, 2025
4cda767
refactor(TomlConfig): minor cleanups for DX
bearomorphism Sep 13, 2025
2fc95b1
style(BaseConfig): update set_key comments and type annotation, remov…
bearomorphism Sep 14, 2025
c85341e
refactor(RestructuredTest): rename variable, fix typo and remove unne…
bearomorphism Sep 15, 2025
5239d16
feat(conventional_commits): allow exclamation in title on BC
bastien31 Sep 1, 2025
25032ba
fix(test): set terminal width for cli tests
bastien31 Sep 9, 2025
abc7acd
refactor(bump): cleanup related to update_version_file
bearomorphism Sep 10, 2025
b181b44
test: simplify assertion
Lee-W Nov 8, 2025
3b499b9
test: replace tmpdir with tmppath
Lee-W Nov 8, 2025
c43197b
feat: add config option for line length warning
Narwhal-fish Aug 11, 2025
3c0a88c
docs(config): add message length limit configuration option
Narwhal-fish Aug 12, 2025
9dce8f1
style: unify YAML quotes style
AdrianDC Aug 13, 2024
0695a2f
fix(commands/version): add missing return
Lee-W Nov 9, 2025
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
81 changes: 33 additions & 48 deletions commitizen/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
import os
import re
from collections import OrderedDict
from collections.abc import Iterable
from collections.abc import Generator, Iterable
from glob import iglob
from logging import getLogger
from string import Template
from typing import cast

from commitizen.defaults import BUMP_MESSAGE, ENCODING, MAJOR, MINOR, PATCH
from commitizen.defaults import BUMP_MESSAGE, MAJOR, MINOR, PATCH
from commitizen.exceptions import CurrentVersionNotFoundError
from commitizen.git import GitCommit, smart_open
from commitizen.version_schemes import Increment, Version
Expand Down Expand Up @@ -64,8 +64,8 @@ def update_version_in_files(
new_version: str,
files: Iterable[str],
*,
check_consistency: bool = False,
encoding: str = ENCODING,
check_consistency: bool,
encoding: str,
) -> list[str]:
"""Change old version to the new one in every file given.
Expand All @@ -75,16 +75,22 @@ def update_version_in_files(
Returns the list of updated files.
"""
# TODO: separate check step and write step
updated = []
for path, regex in _files_and_regexes(files, current_version):
current_version_found, version_file = _bump_with_regex(
path,
current_version,
new_version,
regex,
encoding=encoding,
)
updated_files = []

for path, pattern in _resolve_files_and_regexes(files, current_version):
current_version_found = False
bumped_lines = []

with open(path, encoding=encoding) as version_file:
for line in version_file:
bumped_line = (
line.replace(current_version, new_version)
if pattern.search(line)
else line
)

current_version_found = current_version_found or bumped_line != line
bumped_lines.append(bumped_line)

if check_consistency and not current_version_found:
raise CurrentVersionNotFoundError(
Expand All @@ -93,53 +99,32 @@ def update_version_in_files(
"version_files are possibly inconsistent."
)

bumped_version_file_content = "".join(bumped_lines)

# Write the file out again
with smart_open(path, "w", encoding=encoding) as file:
file.write(version_file)
updated.append(path)
return updated
file.write(bumped_version_file_content)
updated_files.append(path)

return updated_files


def _files_and_regexes(patterns: Iterable[str], version: str) -> list[tuple[str, str]]:
def _resolve_files_and_regexes(
patterns: Iterable[str], version: str
) -> Generator[tuple[str, re.Pattern], None, None]:
"""
Resolve all distinct files with their regexp from a list of glob patterns with optional regexp
"""
out: set[tuple[str, str]] = set()
filepath_set: set[tuple[str, str]] = set()
for pattern in patterns:
drive, tail = os.path.splitdrive(pattern)
path, _, regex = tail.partition(":")
filepath = drive + path
if not regex:
regex = re.escape(version)
regex = regex or re.escape(version)

for file in iglob(filepath):
out.add((file, regex))
filepath_set.update((path, regex) for path in iglob(filepath))

return sorted(out)


def _bump_with_regex(
version_filepath: str,
current_version: str,
new_version: str,
regex: str,
encoding: str = ENCODING,
) -> tuple[bool, str]:
current_version_found = False
lines = []
pattern = re.compile(regex)
with open(version_filepath, encoding=encoding) as f:
for line in f:
if not pattern.search(line):
lines.append(line)
continue

bumped_line = line.replace(current_version, new_version)
if bumped_line != line:
current_version_found = True
lines.append(bumped_line)

return current_version_found, "".join(lines)
return ((path, re.compile(regex)) for path, regex in sorted(filepath_set))


def create_commit_message(
Expand Down
8 changes: 4 additions & 4 deletions commitizen/changelog_formats/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,9 @@ def __init__(self, config: BaseConfig) -> None:
# Constructor needs to be redefined because `Protocol` prevent instantiation by default
# See: https://bugs.python.org/issue44807
self.config = config
self.encoding = self.config.settings["encoding"]
self.tag_format = self.config.settings["tag_format"]
self.tag_rules = TagRules(
scheme=get_version_scheme(self.config.settings),
tag_format=self.tag_format,
tag_format=self.config.settings["tag_format"],
legacy_tag_formats=self.config.settings["legacy_tag_formats"],
ignored_tag_formats=self.config.settings["ignored_tag_formats"],
)
Expand All @@ -37,7 +35,9 @@ def get_metadata(self, filepath: str) -> Metadata:
if not os.path.isfile(filepath):
return Metadata()

with open(filepath, encoding=self.encoding) as changelog_file:
with open(
filepath, encoding=self.config.settings["encoding"]
) as changelog_file:
return self.get_metadata_from_file(changelog_file)

def get_metadata_from_file(self, file: IO[Any]) -> Metadata:
Expand Down
118 changes: 57 additions & 61 deletions commitizen/changelog_formats/restructuredtext.py
Original file line number Diff line number Diff line change
@@ -1,92 +1,88 @@
from __future__ import annotations

import sys
from itertools import zip_longest
from typing import IO, TYPE_CHECKING, Any, Union
from typing import IO

from commitizen.changelog import Metadata

from .base import BaseFormat

if TYPE_CHECKING:
# TypeAlias is Python 3.10+ but backported in typing-extensions
if sys.version_info >= (3, 10):
from typing import TypeAlias
else:
from typing_extensions import TypeAlias


# Can't use `|` operator and native type because of https://bugs.python.org/issue42233 only fixed in 3.10
TitleKind: TypeAlias = Union[str, tuple[str, str]]


class RestructuredText(BaseFormat):
extension = "rst"

def get_metadata_from_file(self, file: IO[Any]) -> Metadata:
def get_metadata_from_file(self, file: IO[str]) -> Metadata:
"""
RestructuredText section titles are not one-line-based,
they spread on 2 or 3 lines and levels are not predefined
but determined byt their occurrence order.
but determined by their occurrence order.

It requires its own algorithm.

For a more generic approach, you need to rely on `docutils`.
"""
meta = Metadata()
unreleased_title_kind: TitleKind | None = None
in_overlined_title = False
lines = file.readlines()
out_metadata = Metadata()
unreleased_title_kind: str | tuple[str, str] | None = None
is_overlined_title = False
lines = [line.strip().lower() for line in file.readlines()]

for index, (first, second, third) in enumerate(
zip_longest(lines, lines[1:], lines[2:], fillvalue="")
):
first = first.strip().lower()
second = second.strip().lower()
third = third.strip().lower()
title: str | None = None
kind: TitleKind | None = None
if self.is_overlined_title(first, second, third):
kind: str | tuple[str, str] | None = None
if _is_overlined_title(first, second, third):
title = second
kind = (first[0], third[0])
in_overlined_title = True
elif not in_overlined_title and self.is_underlined_title(first, second):
is_overlined_title = True
elif not is_overlined_title and _is_underlined_title(first, second):
title = first
kind = second[0]
else:
in_overlined_title = False

if title:
if "unreleased" in title:
unreleased_title_kind = kind
meta.unreleased_start = index
continue
elif unreleased_title_kind and unreleased_title_kind == kind:
meta.unreleased_end = index
# Try to find the latest release done
if version := self.tag_rules.search_version(title):
meta.latest_version = version[0]
meta.latest_version_tag = version[1]
meta.latest_version_position = index
break
if meta.unreleased_start is not None and meta.unreleased_end is None:
meta.unreleased_end = (
meta.latest_version_position if meta.latest_version else index + 1
is_overlined_title = False

if not title:
continue

if "unreleased" in title:
unreleased_title_kind = kind
out_metadata.unreleased_start = index
continue

if unreleased_title_kind and unreleased_title_kind == kind:
out_metadata.unreleased_end = index
# Try to find the latest release done
if version := self.tag_rules.search_version(title):
out_metadata.latest_version = version[0]
out_metadata.latest_version_tag = version[1]
out_metadata.latest_version_position = index
break

if (
out_metadata.unreleased_start is not None
and out_metadata.unreleased_end is None
):
out_metadata.unreleased_end = (
out_metadata.latest_version_position
if out_metadata.latest_version
else len(lines)
)

return meta

def is_overlined_title(self, first: str, second: str, third: str) -> bool:
return (
len(first) >= len(second)
and len(first) == len(third)
and all(char == first[0] for char in first[1:])
and first[0] == third[0]
and self.is_underlined_title(second, third)
)

def is_underlined_title(self, first: str, second: str) -> bool:
return (
len(second) >= len(first)
and not second.isalnum()
and all(char == second[0] for char in second[1:])
)
return out_metadata


def _is_overlined_title(first: str, second: str, third: str) -> bool:
return (
len(first) == len(third) >= len(second)
and first[0] == third[0]
and all(char == first[0] for char in first)
and _is_underlined_title(second, third)
)


def _is_underlined_title(first: str, second: str) -> bool:
return (
len(second) >= len(first)
and not second.isalnum()
and all(char == second[0] for char in second)
)
14 changes: 12 additions & 2 deletions commitizen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,6 @@ def __call__(
{
"name": ["-l", "--message-length-limit"],
"type": int,
"default": 0,
"help": "length limit of the commit message; 0 for no limit",
},
{
Expand Down Expand Up @@ -499,7 +498,6 @@ def __call__(
{
"name": ["-l", "--message-length-limit"],
"type": int,
"default": 0,
"help": "length limit of the commit message; 0 for no limit",
},
],
Expand Down Expand Up @@ -543,6 +541,18 @@ def __call__(
"action": "store_true",
"exclusive_group": "group1",
},
{
"name": ["--major"],
"help": "get just the major version",
"action": "store_true",
"exclusive_group": "group2",
},
{
"name": ["--minor"],
"help": "get just the minor version",
"action": "store_true",
"exclusive_group": "group2",
},
],
},
],
Expand Down
16 changes: 9 additions & 7 deletions commitizen/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ def _try_decode(bytes_: bytes) -> str:
try:
return bytes_.decode("utf-8")
except UnicodeDecodeError:
charset_match = from_bytes(bytes_).best()
if charset_match is None:
raise CharacterSetDecodeError()
try:
return bytes_.decode(charset_match.encoding)
except UnicodeDecodeError as e:
raise CharacterSetDecodeError() from e
pass

charset_match = from_bytes(bytes_).best()
if charset_match is None:
raise CharacterSetDecodeError()
try:
return bytes_.decode(charset_match.encoding)
except UnicodeDecodeError as e:
raise CharacterSetDecodeError() from e


def run(cmd: str, env: Mapping[str, str] | None = None) -> Command:
Expand Down
7 changes: 3 additions & 4 deletions commitizen/commands/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ def __init__(self, config: BaseConfig, arguments: BumpArgs) -> None:
raise NotAGitProjectError()

self.config: BaseConfig = config
self.encoding = config.settings["encoding"]
self.arguments = arguments
self.bump_settings = cast(
BumpArgs,
Expand Down Expand Up @@ -216,8 +215,8 @@ def __call__(self) -> None:

rules = TagRules.from_settings(cast(Settings, self.bump_settings))
current_tag = rules.find_tag_for(git.get_tags(), current_version)
current_tag_version = getattr(
current_tag, "name", rules.normalize_tag(current_version)
current_tag_version = (
current_tag.name if current_tag else rules.normalize_tag(current_version)
)

is_initial = self._is_initial_tag(current_tag, self.arguments["yes"])
Expand Down Expand Up @@ -335,7 +334,7 @@ def __call__(self) -> None:
str(new_version),
self.bump_settings["version_files"],
check_consistency=self.check_consistency,
encoding=self.encoding,
encoding=self.config.settings["encoding"],
)
)

Expand Down
Loading