Skip to content

Commit 265cb98

Browse files
feat: add custom validation
1 parent 701de0a commit 265cb98

File tree

4 files changed

+280
-36
lines changed

4 files changed

+280
-36
lines changed

commitizen/commands/check.py

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from commitizen import factory, git, out
88
from commitizen.config import BaseConfig
99
from commitizen.exceptions import (
10-
CommitMessageLengthExceededError,
1110
InvalidCommandArgumentError,
1211
InvalidCommitMessageError,
1312
NoCommitsFoundError,
@@ -81,26 +80,32 @@ def __call__(self) -> None:
8180
"""Validate if commit messages follows the conventional pattern.
8281
8382
Raises:
84-
InvalidCommitMessageError: if the commit provided not follows the conventional pattern
83+
InvalidCommitMessageError: if the commit provided does not follow the conventional pattern
8584
NoCommitsFoundError: if no commit is found with the given range
8685
"""
8786
commits = self._get_commits()
8887
if not commits:
8988
raise NoCommitsFoundError(f"No commit found with range: '{self.rev_range}'")
9089

9190
pattern = re.compile(self.cz.schema_pattern())
92-
invalid_msgs_content = "\n".join(
93-
f'commit "{commit.rev}": "{commit.message}"'
91+
invalid_commits = [
92+
(commit, check.errors)
9493
for commit in commits
95-
if not self._validate_commit_message(commit.message, pattern, commit.rev)
96-
)
97-
if invalid_msgs_content:
98-
# TODO: capitalize the first letter of the error message for consistency in v5
94+
if not (
95+
check := self.cz.validate_commit_message(
96+
commit_msg=commit.message,
97+
pattern=pattern,
98+
allow_abort=self.allow_abort,
99+
allowed_prefixes=self.allowed_prefixes,
100+
max_msg_length=self.max_msg_length,
101+
commit_hash=commit.rev,
102+
)
103+
).is_valid
104+
]
105+
106+
if invalid_commits:
99107
raise InvalidCommitMessageError(
100-
"commit validation: failed!\n"
101-
"please enter a commit message in the commitizen format.\n"
102-
f"{invalid_msgs_content}\n"
103-
f"pattern: {pattern.pattern}"
108+
self.cz.format_exception_message(invalid_commits)
104109
)
105110
out.success("Commit validation: successful!")
106111

@@ -155,24 +160,3 @@ def _filter_comments(msg: str) -> str:
155160
if not line.startswith("#"):
156161
lines.append(line)
157162
return "\n".join(lines)
158-
159-
def _validate_commit_message(
160-
self, commit_msg: str, pattern: re.Pattern[str], commit_hash: str
161-
) -> bool:
162-
if not commit_msg:
163-
return self.allow_abort
164-
165-
if any(map(commit_msg.startswith, self.allowed_prefixes)):
166-
return True
167-
168-
if self.max_msg_length is not None:
169-
msg_len = len(commit_msg.partition("\n")[0].strip())
170-
if msg_len > self.max_msg_length:
171-
raise CommitMessageLengthExceededError(
172-
f"commit validation: failed!\n"
173-
f"commit message length exceeds the limit.\n"
174-
f'commit "{commit_hash}": "{commit_msg}"\n'
175-
f"message length limit: {self.max_msg_length} (actual: {msg_len})"
176-
)
177-
178-
return bool(pattern.match(commit_msg))

commitizen/cz/base.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
from __future__ import annotations
22

3+
import re
34
from abc import ABCMeta, abstractmethod
45
from collections.abc import Iterable, Mapping
5-
from typing import Any, Callable, Protocol
6+
from typing import Any, Callable, NamedTuple, Protocol
67

78
from jinja2 import BaseLoader, PackageLoader
89
from prompt_toolkit.styles import Style
910

1011
from commitizen import git
1112
from commitizen.config.base_config import BaseConfig
13+
from commitizen.exceptions import CommitMessageLengthExceededError
1214
from commitizen.question import CzQuestion
1315

1416

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

2628

29+
class ValidationResult(NamedTuple):
30+
is_valid: bool
31+
errors: list
32+
33+
2734
class BaseCommitizen(metaclass=ABCMeta):
2835
bump_pattern: str | None = None
2936
bump_map: dict[str, str] | None = None
@@ -41,7 +48,7 @@ class BaseCommitizen(metaclass=ABCMeta):
4148
("disabled", "fg:#858585 italic"),
4249
]
4350

44-
# The whole subject will be parsed as message by default
51+
# The whole subject will be parsed as a message by default
4552
# This allows supporting changelog for any rule system.
4653
# It can be modified per rule
4754
commit_parser: str | None = r"(?P<message>.*)"
@@ -99,3 +106,55 @@ def schema_pattern(self) -> str:
99106
@abstractmethod
100107
def info(self) -> str:
101108
"""Information about the standardized commit message."""
109+
110+
def validate_commit_message(
111+
self,
112+
*,
113+
commit_msg: str,
114+
pattern: re.Pattern[str],
115+
allow_abort: bool,
116+
allowed_prefixes: list[str],
117+
max_msg_length: int | None,
118+
commit_hash: str,
119+
) -> ValidationResult:
120+
"""Validate commit message against the pattern."""
121+
if not commit_msg:
122+
return ValidationResult(
123+
allow_abort, [] if allow_abort else ["commit message is empty"]
124+
)
125+
126+
if any(map(commit_msg.startswith, allowed_prefixes)):
127+
return ValidationResult(True, [])
128+
129+
if max_msg_length is not None:
130+
msg_len = len(commit_msg.partition("\n")[0].strip())
131+
if msg_len > max_msg_length:
132+
# TODO: capitalize the first letter of the error message for consistency in v5
133+
raise CommitMessageLengthExceededError(
134+
f"commit validation: failed!\n"
135+
f"commit message length exceeds the limit.\n"
136+
f'commit "{commit_hash}": "{commit_msg}"\n'
137+
f"message length limit: {max_msg_length} (actual: {msg_len})"
138+
)
139+
140+
return ValidationResult(
141+
bool(pattern.match(commit_msg)),
142+
[f"pattern: {pattern.pattern}"],
143+
)
144+
145+
def format_exception_message(
146+
self, invalid_commits: list[tuple[git.GitCommit, list]]
147+
) -> str:
148+
"""Format commit errors."""
149+
displayed_msgs_content = "\n".join(
150+
[
151+
f'commit "{commit.rev}": "{commit.message}\n"' + "\n".join(errors)
152+
for commit, errors in invalid_commits
153+
]
154+
)
155+
# TODO: capitalize the first letter of the error message for consistency in v5
156+
return (
157+
"commit validation: failed!\n"
158+
"please enter a commit message in the commitizen format.\n"
159+
f"{displayed_msgs_content}"
160+
)

docs/customization.md

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ cookiecutter gh:commitizen-tools/commitizen_cz_template
207207

208208
See [commitizen_cz_template](https://github.com/commitizen-tools/commitizen_cz_template) for details.
209209

210-
Once you publish your rules, you can send us a PR to the [Third-party section](./third-party-plugins/about.md).
210+
Once you publish your rules, you can send us a PR to the [Third-party section](./third-party-commitizen.md).
211211

212212
### Custom commit rules
213213

@@ -312,6 +312,73 @@ cz -n cz_strange bump
312312

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

315+
### Custom commit validation and error message
316+
317+
The commit message validation can be customized by overriding the `validate_commit_message` and `format_error_message`
318+
methods from `BaseCommitizen`. This allows for a more detailed feedback to the user where the error originates from.
319+
320+
```python
321+
import re
322+
323+
from commitizen.cz.base import BaseCommitizen, ValidationResult
324+
from commitizen import git
325+
326+
327+
class CustomValidationCz(BaseCommitizen):
328+
def validate_commit_message(
329+
self,
330+
*,
331+
commit_msg: str,
332+
pattern: str | None,
333+
allow_abort: bool,
334+
allowed_prefixes: list[str],
335+
max_msg_length: int,
336+
) -> ValidationResult:
337+
"""Validate commit message against the pattern."""
338+
if not commit_msg:
339+
return allow_abort, [] if allow_abort else [f"commit message is empty"]
340+
341+
if pattern is None:
342+
return True, []
343+
344+
if any(map(commit_msg.startswith, allowed_prefixes)):
345+
return True, []
346+
if max_msg_length:
347+
msg_len = len(commit_msg.partition("\n")[0].strip())
348+
if msg_len > max_msg_length:
349+
return False, [
350+
f"commit message is too long. Max length is {max_msg_length}"
351+
]
352+
pattern_match = re.match(pattern, commit_msg)
353+
if pattern_match:
354+
return True, []
355+
else:
356+
# Perform additional validation of the commit message format
357+
# and add custom error messages as needed
358+
return False, ["commit message does not match the pattern"]
359+
360+
def format_exception_message(
361+
self, ill_formated_commits: list[tuple[git.GitCommit, list]]
362+
) -> str:
363+
"""Format commit errors."""
364+
displayed_msgs_content = "\n".join(
365+
[
366+
(
367+
f'commit "{commit.rev}": "{commit.message}"'
368+
f"errors:\n"
369+
"\n".join((f"- {error}" for error in errors))
370+
)
371+
for commit, errors in ill_formated_commits
372+
]
373+
)
374+
return (
375+
"commit validation: failed!\n"
376+
"please enter a commit message in the commitizen format.\n"
377+
f"{displayed_msgs_content}\n"
378+
f"pattern: {self.schema_pattern()}"
379+
)
380+
```
381+
315382
### Custom changelog generator
316383

317384
The changelog generator should just work in a very basic manner without touching anything.

0 commit comments

Comments
 (0)