Skip to content

Commit 006026a

Browse files
committed
feat: configurable commit validation
Allow clients overriding the BaseCommitizen object to have more custom commit message validation beyond just matching a regex schema. This lets clients have custom error messages and more intelligent pattern matching.
1 parent de5c39c commit 006026a

File tree

7 files changed

+186
-44
lines changed

7 files changed

+186
-44
lines changed

commitizen/commands/check.py

+3-39
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
import os
2-
import re
32
import sys
43
from typing import Any, Dict, Optional
54

65
from commitizen import factory, git, out
76
from commitizen.config import BaseConfig
8-
from commitizen.exceptions import (
9-
InvalidCommandArgumentError,
10-
InvalidCommitMessageError,
11-
NoCommitsFoundError,
12-
)
7+
from commitizen.exceptions import InvalidCommandArgumentError, NoCommitsFoundError
138

149

1510
class Check:
@@ -54,31 +49,13 @@ def __call__(self):
5449
"""Validate if commit messages follows the conventional pattern.
5550
5651
Raises:
57-
InvalidCommitMessageError: if the commit provided not follows the conventional pattern
52+
InvalidCommitMessageError: if the provided commit does not follow the conventional pattern
5853
"""
5954
commits = self._get_commits()
6055
if not commits:
6156
raise NoCommitsFoundError(f"No commit found with range: '{self.rev_range}'")
6257

63-
pattern = self.cz.schema_pattern()
64-
ill_formated_commits = [
65-
commit
66-
for commit in commits
67-
if not self.validate_commit_message(commit.message, pattern)
68-
]
69-
displayed_msgs_content = "\n".join(
70-
[
71-
f'commit "{commit.rev}": "{commit.message}"'
72-
for commit in ill_formated_commits
73-
]
74-
)
75-
if displayed_msgs_content:
76-
raise InvalidCommitMessageError(
77-
"commit validation: failed!\n"
78-
"please enter a commit message in the commitizen format.\n"
79-
f"{displayed_msgs_content}\n"
80-
f"pattern: {pattern}"
81-
)
58+
self.cz.validate_commits(commits, self.allow_abort)
8259
out.success("Commit validation: successful!")
8360

8461
def _get_commits(self):
@@ -128,16 +105,3 @@ def _filter_comments(msg: str) -> str:
128105
if not line.startswith("#"):
129106
lines.append(line)
130107
return "\n".join(lines)
131-
132-
def validate_commit_message(self, commit_msg: str, pattern: str) -> bool:
133-
if not commit_msg:
134-
return self.allow_abort
135-
if (
136-
commit_msg.startswith("Merge")
137-
or commit_msg.startswith("Revert")
138-
or commit_msg.startswith("Pull request")
139-
or commit_msg.startswith("fixup!")
140-
or commit_msg.startswith("squash!")
141-
):
142-
return True
143-
return bool(re.match(pattern, commit_msg))

commitizen/cz/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from commitizen.cz.base import BaseCommitizen
77
from commitizen.cz.conventional_commits import ConventionalCommitsCz
8-
from commitizen.cz.customize import CustomizeCommitsCz
8+
from commitizen.cz.customize import CustomizeCommitsCz, CustomizeCommitValidationCz
99
from commitizen.cz.jira import JiraSmartCz
1010

1111

@@ -34,6 +34,7 @@ def discover_plugins(path: Iterable[str] = None) -> Dict[str, Type[BaseCommitize
3434
"cz_conventional_commits": ConventionalCommitsCz,
3535
"cz_jira": JiraSmartCz,
3636
"cz_customize": CustomizeCommitsCz,
37+
"cz_custom_validation": CustomizeCommitValidationCz,
3738
}
3839

3940
registry.update(discover_plugins())

commitizen/cz/base.py

+43
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from abc import ABCMeta, abstractmethod
23
from typing import Callable, Dict, List, Optional, Tuple
34

@@ -6,6 +7,7 @@
67
from commitizen import git
78
from commitizen.config.base_config import BaseConfig
89
from commitizen.defaults import Questions
10+
from commitizen.exceptions import InvalidCommitMessageError
911

1012

1113
class BaseCommitizen(metaclass=ABCMeta):
@@ -74,6 +76,47 @@ def schema_pattern(self) -> Optional[str]:
7476
"""Regex matching the schema used for message validation."""
7577
raise NotImplementedError("Not Implemented yet")
7678

79+
def validate_commit_message(
80+
self, commit: git.GitCommit, pattern: str, allow_abort: bool
81+
) -> bool:
82+
if not commit.message:
83+
return allow_abort
84+
if (
85+
commit.message.startswith("Merge")
86+
or commit.message.startswith("Revert")
87+
or commit.message.startswith("Pull request")
88+
or commit.message.startswith("fixup!")
89+
or commit.message.startswith("squash!")
90+
):
91+
return True
92+
return bool(re.match(pattern, commit.message))
93+
94+
def validate_commits(self, commits: List[git.GitCommit], allow_abort: bool):
95+
"""
96+
Validate a commit. Invokes schema_pattern by default.
97+
Raises:
98+
InvalidCommitMessageError: if the provided commit does not follow the conventional pattern
99+
"""
100+
101+
pattern = self.schema_pattern()
102+
assert pattern is not None
103+
104+
displayed_msgs_content = "\n".join(
105+
[
106+
f'commit "{commit.rev}": "{commit.message}"'
107+
for commit in commits
108+
if not self.validate_commit_message(commit, pattern, allow_abort)
109+
]
110+
)
111+
112+
if displayed_msgs_content:
113+
raise InvalidCommitMessageError(
114+
"commit validation: failed!\n"
115+
"please enter a commit message in the commitizen format.\n"
116+
f"{displayed_msgs_content}\n"
117+
f"pattern: {pattern}"
118+
)
119+
77120
def info(self) -> Optional[str]:
78121
"""Information about the standardized commit message."""
79122
raise NotImplementedError("Not Implemented yet")

commitizen/cz/customize/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from .customize import CustomizeCommitsCz # noqa
1+
from .customize import CustomizeCommitsCz, CustomizeCommitValidationCz # noqa

commitizen/cz/customize/customize.py

+56-2
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@
33
except ImportError:
44
from string import Template # type: ignore
55

6+
import re
67
from typing import Optional
78

89
from commitizen import defaults
910
from commitizen.config import BaseConfig
1011
from commitizen.cz.base import BaseCommitizen
1112
from commitizen.defaults import Questions
12-
from commitizen.exceptions import MissingCzCustomizeConfigError
13+
from commitizen.exceptions import (
14+
InvalidCommitMessageError,
15+
MissingCzCustomizeConfigError,
16+
)
1317

14-
__all__ = ["CustomizeCommitsCz"]
18+
__all__ = ["CustomizeCommitsCz", "CustomizeCommitValidationCz"]
1519

1620

1721
class CustomizeCommitsCz(BaseCommitizen):
@@ -79,3 +83,53 @@ def info(self) -> Optional[str]:
7983
elif info:
8084
return info
8185
return None
86+
87+
88+
class CustomizeCommitValidationCz(CustomizeCommitsCz):
89+
def validate_commit(self, pattern, commit, allow_abort):
90+
"""
91+
Validates that a commit message doesn't contain certain tokens.
92+
"""
93+
if not commit.message:
94+
return allow_abort
95+
96+
invalid_tokens = ["Merge", "Revert", "Pull request", "fixup!", "squash!"]
97+
for token in invalid_tokens:
98+
if commit.message.startswith(token):
99+
raise InvalidCommitMessageError(
100+
f"Commits may not start with the token {token}."
101+
)
102+
103+
return re.match(pattern, commit.message)
104+
105+
def validate_commits(self, commits, allow_abort):
106+
"""
107+
Validates a list of commits against the configured commit validation schema.
108+
See the schema() and example() functions for examples.
109+
"""
110+
111+
pattern = self.schema_pattern()
112+
113+
displayed_msgs_content = []
114+
for commit in commits:
115+
message = ""
116+
valid = False
117+
try:
118+
valid = self.validate_commit(pattern, commit, allow_abort)
119+
except InvalidCommitMessageError as e:
120+
message = e.message
121+
122+
if not valid:
123+
displayed_msgs_content.append(
124+
f"commit {commit.rev}\n"
125+
f"Author: {commit.author} <{commit.author_email}>\n\n"
126+
f"{commit.message}\n\n"
127+
f"commit validation: failed! {message}\n"
128+
)
129+
130+
if displayed_msgs_content:
131+
displayed_msgs = "\n".join(displayed_msgs_content)
132+
raise InvalidCommitMessageError(
133+
f"{displayed_msgs}\n"
134+
"Please enter a commit message in the commitizen format.\n"
135+
)

docs/customization.md

+40
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,46 @@ cz -n cz_strange bump
317317

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

320+
### Custom Commit Validation
321+
322+
The default implementation of `validate_commits` will call `validate_commit` for each commit passed as input to the `check` command, and evaluate
323+
it against the `schema_pattern` regular expression. You can override those 3 functions on `BaseCommitizen` for different levels of flexibility.
324+
325+
```python
326+
import re
327+
from commitizen.cz.base import BaseCommitizen
328+
from commitizen.git import GitCommit
329+
330+
class StrangeCommitizen(BaseCommitizen):
331+
def schema_pattern(self) -> Optional[str]:
332+
# collect the different prefix options into a regex
333+
choices = ["feat", "fix", "chore", "bump"]
334+
prefixes_regex = r"|".join(choices)
335+
336+
return (
337+
f"({prefixes_regex})" # different prefixes
338+
"(\(\S+\))?!?:(\s.*)" # scope & subject body
339+
)
340+
341+
def validate_commit(self, pattern: str, commit: GitCommit, allow_abort: bool) -> bool:
342+
return allow_abort if not re.match(pattern, commit.message) else True
343+
344+
def validate_commits(self, commits: List[GitCommit], allow_abort: bool):
345+
pattern = self.schema_pattern()
346+
invalid_commits = "\n".join([
347+
(
348+
f'"{commit.rev}": "{commit.message}"\n'
349+
"commit validation failed!"
350+
)
351+
for commit in commits
352+
if not self.validate_commit(pattern, commit, allow_abort)
353+
])
354+
355+
if invalid_commits:
356+
raise InvalidCommitMessageError(invalid_commits)
357+
358+
```
359+
320360
### Custom changelog generator
321361

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

tests/commands/test_check_command.py

+41-1
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,46 @@ def test_check_conventional_commit_succeeds(mocker: MockFixture, capsys):
128128
assert "Commit validation: successful!" in out
129129

130130

131+
def test_check_custom_validation_succeeds(mocker, capsys, config_customize):
132+
testargs = [
133+
"cz",
134+
"-n",
135+
"cz_custom_validation",
136+
"check",
137+
"--commit-msg-file",
138+
"some_file",
139+
]
140+
mocker.patch.object(sys, "argv", testargs)
141+
mocker.patch(
142+
"commitizen.commands.check.open",
143+
mocker.mock_open(read_data="fix(scope): some commit message"),
144+
)
145+
mocker.patch("commitizen.config.read_cfg", return_value=config_customize)
146+
cli.main()
147+
out, _ = capsys.readouterr()
148+
assert "Commit validation: successful!" in out
149+
150+
151+
def test_check_custom_validation_fails(mocker, config_customize):
152+
testargs = [
153+
"cz",
154+
"-n",
155+
"cz_custom_validation",
156+
"check",
157+
"--commit-msg-file",
158+
"some_file",
159+
]
160+
mocker.patch.object(sys, "argv", testargs)
161+
mocker.patch(
162+
"commitizen.commands.check.open",
163+
mocker.mock_open(read_data="fixup! fix(scope): some commit message"),
164+
)
165+
mocker.patch("commitizen.config.read_cfg", return_value=config_customize)
166+
with pytest.raises(InvalidCommitMessageError) as excinfo:
167+
cli.main()
168+
assert "commit validation: failed!" in str(excinfo.value)
169+
170+
131171
@pytest.mark.parametrize(
132172
"commit_msg",
133173
(
@@ -332,7 +372,7 @@ def test_check_command_with_pipe_message_and_failed(mocker: MockFixture):
332372
assert "commit validation: failed!" in str(excinfo.value)
333373

334374

335-
def test_check_command_with_comment_in_messege_file(mocker: MockFixture, capsys):
375+
def test_check_command_with_comment_in_message_file(mocker: MockFixture, capsys):
336376
testargs = ["cz", "check", "--commit-msg-file", "some_file"]
337377
mocker.patch.object(sys, "argv", testargs)
338378
mocker.patch(

0 commit comments

Comments
 (0)