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
50 changes: 17 additions & 33 deletions commitizen/commands/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from commitizen import factory, git, out
from commitizen.config import BaseConfig
from commitizen.exceptions import (
CommitMessageLengthExceededError,
InvalidCommandArgumentError,
InvalidCommitMessageError,
NoCommitsFoundError,
Expand Down Expand Up @@ -81,26 +80,32 @@ def __call__(self) -> None:
"""Validate if commit messages follows the conventional pattern.

Raises:
InvalidCommitMessageError: if the commit provided not follows the conventional pattern
InvalidCommitMessageError: if the commit provided does not follow the conventional pattern
NoCommitsFoundError: if no commit is found with the given range
"""
commits = self._get_commits()
if not commits:
raise NoCommitsFoundError(f"No commit found with range: '{self.rev_range}'")

pattern = re.compile(self.cz.schema_pattern())
invalid_msgs_content = "\n".join(
f'commit "{commit.rev}": "{commit.message}"'
invalid_commits = [
(commit, check.errors)
for commit in commits
if not self._validate_commit_message(commit.message, pattern, commit.rev)
)
if invalid_msgs_content:
# TODO: capitalize the first letter of the error message for consistency in v5
if not (
check := self.cz.validate_commit_message(
commit_msg=commit.message,
pattern=pattern,
allow_abort=self.allow_abort,
allowed_prefixes=self.allowed_prefixes,
max_msg_length=self.max_msg_length,
commit_hash=commit.rev,
)
).is_valid
]

if invalid_commits:
raise InvalidCommitMessageError(
"commit validation: failed!\n"
"please enter a commit message in the commitizen format.\n"
f"{invalid_msgs_content}\n"
f"pattern: {pattern.pattern}"
self.cz.format_exception_message(invalid_commits)
)
out.success("Commit validation: successful!")

Expand Down Expand Up @@ -155,24 +160,3 @@ def _filter_comments(msg: str) -> str:
if not line.startswith("#"):
lines.append(line)
return "\n".join(lines)

def _validate_commit_message(
self, commit_msg: str, pattern: re.Pattern[str], commit_hash: str
) -> bool:
if not commit_msg:
return self.allow_abort

if any(map(commit_msg.startswith, self.allowed_prefixes)):
return True

if self.max_msg_length is not None:
msg_len = len(commit_msg.partition("\n")[0].strip())
if msg_len > self.max_msg_length:
raise CommitMessageLengthExceededError(
f"commit validation: failed!\n"
f"commit message length exceeds the limit.\n"
f'commit "{commit_hash}": "{commit_msg}"\n'
f"message length limit: {self.max_msg_length} (actual: {msg_len})"
)

return bool(pattern.match(commit_msg))
63 changes: 61 additions & 2 deletions commitizen/cz/base.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
from __future__ import annotations

import re
from abc import ABCMeta, abstractmethod
from collections.abc import Iterable, Mapping
from typing import Any, Callable, Protocol
from typing import Any, Callable, NamedTuple, Protocol

from jinja2 import BaseLoader, PackageLoader
from prompt_toolkit.styles import Style

from commitizen import git
from commitizen.config.base_config import BaseConfig
from commitizen.exceptions import CommitMessageLengthExceededError
from commitizen.question import CzQuestion


Expand All @@ -24,6 +26,11 @@ def __call__(
) -> dict[str, Any]: ...


class ValidationResult(NamedTuple):
is_valid: bool
errors: list


class BaseCommitizen(metaclass=ABCMeta):
bump_pattern: str | None = None
bump_map: dict[str, str] | None = None
Expand All @@ -41,7 +48,7 @@ class BaseCommitizen(metaclass=ABCMeta):
("disabled", "fg:#858585 italic"),
]

# The whole subject will be parsed as message by default
# The whole subject will be parsed as a message by default
# This allows supporting changelog for any rule system.
# It can be modified per rule
commit_parser: str | None = r"(?P<message>.*)"
Expand Down Expand Up @@ -99,3 +106,55 @@ def schema_pattern(self) -> str:
@abstractmethod
def info(self) -> str:
"""Information about the standardized commit message."""

def validate_commit_message(
self,
*,
commit_msg: str,
pattern: re.Pattern[str],
allow_abort: bool,
allowed_prefixes: list[str],
max_msg_length: int | None,
commit_hash: str,
) -> ValidationResult:
"""Validate commit message against the pattern."""
if not commit_msg:
return ValidationResult(
allow_abort, [] if allow_abort else ["commit message is empty"]
)

if any(map(commit_msg.startswith, allowed_prefixes)):
return ValidationResult(True, [])

if max_msg_length is not None:
msg_len = len(commit_msg.partition("\n")[0].strip())
Copy link
Member

Choose a reason for hiding this comment

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

Do we want user perceived length? Unicode characters may take more than 1 char, if we want the user perceived length we'll need to measure "graphemes". For example, an emoji like 🎏 may take up to 4 chars. And languages outside english may have the same problem.

What do you think @Lee-W ?

Copy link
Member

Choose a reason for hiding this comment

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

There are not many library options in python:

  • uniseg-py, last release: Jan 23, 2025, version 0.10.0, no dependencies, typed, supports Unicode 16
  • grapheme, relatively popular, last release 2020, no dependencies, no typed, supports Unicode 13
  • pyuegc, last release Jan 14, 2025, version 16.0.3, not sure I like the code, no typing. Supports Unicode 16.0.3
  • unicode-segmentation-py bindings to the well-maintained rust version, last release Mar 22, 2025

Tracking issue on cpython:
python/cpython#74902

Copy link
Member

Choose a reason for hiding this comment

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

Hmmm.. that's indeed an issue 🤔 but I guess that's the best we can do for the time being?

Copy link
Author

Choose a reason for hiding this comment

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

I can agree that this might be something to consider, but maybe not for this PR. This is just a copy over from the existing functionality.

if msg_len > max_msg_length:
# TODO: capitalize the first letter of the error message for consistency in v5
raise CommitMessageLengthExceededError(
f"commit validation: failed!\n"
f"commit message length exceeds the limit.\n"
f'commit "{commit_hash}": "{commit_msg}"\n'
f"message length limit: {max_msg_length} (actual: {msg_len})"
)

return ValidationResult(
bool(pattern.match(commit_msg)),
[f"pattern: {pattern.pattern}"],
)

def format_exception_message(
self, invalid_commits: list[tuple[git.GitCommit, list]]
) -> str:
"""Format commit errors."""
displayed_msgs_content = "\n".join(
[
f'commit "{commit.rev}": "{commit.message}\n"' + "\n".join(errors)
for commit, errors in invalid_commits
]
)
# TODO: capitalize the first letter of the error message for consistency in v5
return (
"commit validation: failed!\n"
"please enter a commit message in the commitizen format.\n"
f"{displayed_msgs_content}"
)
67 changes: 67 additions & 0 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,73 @@ cz -n cz_strange bump

[convcomms]: https://github.com/commitizen-tools/commitizen/blob/master/commitizen/cz/conventional_commits/conventional_commits.py

### Custom commit validation and error message

The commit message validation can be customized by overriding the `validate_commit_message` and `format_error_message`
methods from `BaseCommitizen`. This allows for a more detailed feedback to the user where the error originates from.

```python
import re

from commitizen.cz.base import BaseCommitizen
from commitizen import git


class CustomValidationCz(BaseCommitizen):
def validate_commit_message(
self,
*,
commit_msg: str,
pattern: str | None,
allow_abort: bool,
allowed_prefixes: list[str],
max_msg_length: int,
) -> tuple[bool, list]:
"""Validate commit message against the pattern."""
if not commit_msg:
return allow_abort, [] if allow_abort else [f"commit message is empty"]

if pattern is None:
return True, []

if any(map(commit_msg.startswith, allowed_prefixes)):
return True, []
if max_msg_length:
msg_len = len(commit_msg.partition("\n")[0].strip())
if msg_len > max_msg_length:
return False, [
f"commit message is too long. Max length is {max_msg_length}"
]
pattern_match = re.match(pattern, commit_msg)
if pattern_match:
return True, []
else:
# Perform additional validation of the commit message format
# and add custom error messages as needed
return False, ["commit message does not match the pattern"]

def format_exception_message(
self, ill_formated_commits: list[tuple[git.GitCommit, list]]
) -> str:
"""Format commit errors."""
displayed_msgs_content = "\n".join(
[
(
f'commit "{commit.rev}": "{commit.message}"'
f"errors:\n"
"\n".join((f"- {error}" for error in errors))
)
for commit, errors in ill_formated_commits
]
)
return (
"commit validation: failed!\n"
"please enter a commit message in the commitizen format.\n"
f"{displayed_msgs_content}\n"
f"pattern: {self.schema_pattern()}"
)
```

### Custom changelog generator

The changelog generator should just work in a very basic manner without touching anything.
Expand Down
41 changes: 41 additions & 0 deletions tests/commands/test_check_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,3 +517,44 @@ def test_check_command_cli_overrides_config_message_length_limit(
config=config,
arguments={"message": message, "message_length_limit": None},
)


@pytest.mark.usefixtures("use_cz_custom_validator")
def test_check_command_with_custom_validator_succeed(mocker: MockFixture, capsys):
testargs = [
"cz",
"--name",
"cz_custom_validator",
"check",
"--commit-msg-file",
"some_file",
]
mocker.patch.object(sys, "argv", testargs)
mocker.patch(
"commitizen.commands.check.open",
mocker.mock_open(read_data="ABC-123: add commitizen pre-commit hook"),
)
cli.main()
out, _ = capsys.readouterr()
assert "Commit validation: successful!" in out


@pytest.mark.usefixtures("use_cz_custom_validator")
def test_check_command_with_custom_validator_failed(mocker: MockFixture):
testargs = [
"cz",
"--name",
"cz_custom_validator",
"check",
"--commit-msg-file",
"some_file",
]
mocker.patch.object(sys, "argv", testargs)
mocker.patch(
"commitizen.commands.check.open",
mocker.mock_open(read_data="ABC-123 add commitizen pre-commit hook"),
)
with pytest.raises(InvalidCommitMessageError) as excinfo:
cli.main()
assert "commit validation: failed!" in str(excinfo.value)
assert "pattern: " in str(excinfo.value)
Loading