From 6828582a29803586d1803e5d54a461543a88eca1 Mon Sep 17 00:00:00 2001 From: catfish Date: Mon, 11 Aug 2025 23:54:31 +0800 Subject: [PATCH 1/4] feat: implement message length limit for commit messages --- commitizen/cli.py | 2 - commitizen/commands/check.py | 16 ++++++- commitizen/commands/commit.py | 7 ++- commitizen/defaults.py | 2 + tests/commands/test_check_command.py | 62 ++++++++++++++++++++++++++- tests/commands/test_commit_command.py | 59 +++++++++++++++++++++++++ tests/test_conf.py | 2 + 7 files changed, 144 insertions(+), 6 deletions(-) diff --git a/commitizen/cli.py b/commitizen/cli.py index a73377fe45..5edd23f695 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -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", }, { @@ -492,7 +491,6 @@ def __call__( { "name": ["-l", "--message-length-limit"], "type": int, - "default": 0, "help": "length limit of the commit message; 0 for no limit", }, ], diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index 69147bcfbe..58acb187bb 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -7,6 +7,7 @@ from commitizen import factory, git, out from commitizen.config import BaseConfig from commitizen.exceptions import ( + CommitMessageLengthExceededError, InvalidCommandArgumentError, InvalidCommitMessageError, NoCommitsFoundError, @@ -40,7 +41,13 @@ def __init__(self, config: BaseConfig, arguments: CheckArgs, *args: object) -> N self.allow_abort = bool( arguments.get("allow_abort", config.settings["allow_abort"]) ) - self.max_msg_length = arguments.get("message_length_limit", 0) + + # Use command line argument if provided, otherwise use config setting + cmd_length_limit = arguments.get("message_length_limit") + if cmd_length_limit is None: + self.max_msg_length = config.settings.get("message_length_limit", 0) + else: + self.max_msg_length = cmd_length_limit # we need to distinguish between None and [], which is a valid value allowed_prefixes = arguments.get("allowed_prefixes") @@ -154,6 +161,11 @@ def _validate_commit_message( if self.max_msg_length: msg_len = len(commit_msg.partition("\n")[0].strip()) if msg_len > self.max_msg_length: - return False + raise CommitMessageLengthExceededError( + f"commit validation: failed!\n" + f"commit message length exceeds the limit.\n" + f'commit "": "{commit_msg}"\n' + f"message length limit: {self.max_msg_length} (actual: {msg_len})" + ) return bool(pattern.match(commit_msg)) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 19bb72fb00..9dbd226e3d 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -81,7 +81,12 @@ def _prompt_commit_questions(self) -> str: message = cz.message(answers) message_len = len(message.partition("\n")[0].strip()) - message_length_limit = self.arguments.get("message_length_limit", 0) + + + message_length_limit = self.arguments.get("message_length_limit") + if message_length_limit is None: + message_length_limit = self.config.settings.get("message_length_limit", 0) + if 0 < message_length_limit < message_len: raise CommitMessageLengthExceededError( f"Length of commit message exceeds limit ({message_len}/{message_length_limit})" diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 94d4d97b22..792cc770a6 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -46,6 +46,7 @@ class Settings(TypedDict, total=False): ignored_tag_formats: Sequence[str] legacy_tag_formats: Sequence[str] major_version_zero: bool + message_length_limit: int name: str post_bump_hooks: list[str] | None pre_bump_hooks: list[str] | None @@ -108,6 +109,7 @@ class Settings(TypedDict, total=False): "always_signoff": False, "template": None, # default provided by plugin "extras": {}, + "message_length_limit": 0, # 0 for no limit } MAJOR = "MAJOR" diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py index d95a173d8a..1a325d7f1b 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -8,6 +8,7 @@ from commitizen import cli, commands, git from commitizen.exceptions import ( + CommitMessageLengthExceededError, InvalidCommandArgumentError, InvalidCommitMessageError, NoCommitsFoundError, @@ -449,6 +450,65 @@ def test_check_command_with_message_length_limit_exceeded(config, mocker: MockFi arguments={"message": message, "message_length_limit": len(message) - 1}, ) - with pytest.raises(InvalidCommitMessageError): + with pytest.raises(CommitMessageLengthExceededError): check_cmd() error_mock.assert_called_once() + + +def test_check_command_with_config_message_length_limit(config, mocker: MockFixture): + success_mock = mocker.patch("commitizen.out.success") + message = "fix(scope): some commit message" + + config.settings["message_length_limit"] = len(message) + 1 + + check_cmd = commands.Check( + config=config, + arguments={"message": message}, + ) + + check_cmd() + success_mock.assert_called_once() + + +def test_check_command_with_config_message_length_limit_exceeded( + config, mocker: MockFixture +): + error_mock = mocker.patch("commitizen.out.error") + message = "fix(scope): some commit message" + + config.settings["message_length_limit"] = len(message) - 1 + + check_cmd = commands.Check( + config=config, + arguments={"message": message}, + ) + + with pytest.raises(CommitMessageLengthExceededError): + check_cmd() + error_mock.assert_called_once() + + +def test_check_command_cli_overrides_config_message_length_limit( + config, mocker: MockFixture +): + success_mock = mocker.patch("commitizen.out.success") + message = "fix(scope): some commit message" + + config.settings["message_length_limit"] = len(message) - 1 + + check_cmd = commands.Check( + config=config, + arguments={"message": message, "message_length_limit": len(message) + 1}, + ) + + check_cmd() + success_mock.assert_called_once() + + success_mock.reset_mock() + check_cmd = commands.Check( + config=config, + arguments={"message": message, "message_length_limit": 0}, + ) + + check_cmd() + success_mock.assert_called_once() diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 930e1a7a9b..6a247f7783 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -554,3 +554,62 @@ def test_commit_when_nothing_added_to_commit(config, mocker: MockFixture, out): commit_mock.assert_called_once() error_mock.assert_called_once_with(out) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_config_message_length_limit(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prefix = "feat" + subject = "random subject" + message_length = len(prefix) + len(": ") + len(subject) + prompt_mock.return_value = { + "prefix": prefix, + "subject": subject, + "scope": "", + "is_breaking_change": False, + "body": "random body", + "footer": "random footer", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + config.settings["message_length_limit"] = message_length + commands.Commit(config, {})() + success_mock.assert_called_once() + + config.settings["message_length_limit"] = message_length - 1 + with pytest.raises(CommitMessageLengthExceededError): + commands.Commit(config, {})() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_cli_overrides_config_message_length_limit( + config, mocker: MockFixture +): + prompt_mock = mocker.patch("questionary.prompt") + prefix = "feat" + subject = "random subject" + message_length = len(prefix) + len(": ") + len(subject) + prompt_mock.return_value = { + "prefix": prefix, + "subject": subject, + "scope": "", + "is_breaking_change": False, + "body": "random body", + "footer": "random footer", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + config.settings["message_length_limit"] = message_length - 1 + + commands.Commit(config, {"message_length_limit": message_length})() + success_mock.assert_called_once() + + success_mock.reset_mock() + commands.Commit(config, {"message_length_limit": 0})() + success_mock.assert_called_once() diff --git a/tests/test_conf.py b/tests/test_conf.py index f89a0049f4..3afd37dc3a 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -95,6 +95,7 @@ "always_signoff": False, "template": None, "extras": {}, + "message_length_limit": 0, } _new_settings: dict[str, Any] = { @@ -126,6 +127,7 @@ "always_signoff": False, "template": None, "extras": {}, + "message_length_limit": 0, } From c300b70275aacc41a4094880617490b92a86bf8b Mon Sep 17 00:00:00 2001 From: catfish Date: Tue, 12 Aug 2025 13:06:27 +0800 Subject: [PATCH 2/4] docs(config): add message length limit configuration option --- commitizen/commands/commit.py | 1 - docs/config.md | 8 ++++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 9dbd226e3d..0ce9180d7c 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -82,7 +82,6 @@ def _prompt_commit_questions(self) -> str: message = cz.message(answers) message_len = len(message.partition("\n")[0].strip()) - message_length_limit = self.arguments.get("message_length_limit") if message_length_limit is None: message_length_limit = self.config.settings.get("message_length_limit", 0) diff --git a/docs/config.md b/docs/config.md index 649881daec..a2ac0cb8b3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -119,6 +119,14 @@ Default: `false` Disallow empty commit messages, useful in CI. [Read more][allow_abort] +### `message_length_limit` + +Type: `int` + +Default: `0` + +Maximum length of the commit message. Setting it to `0` disables the length limit. It can be overridden by the `-l/--message-length-limit` command line argument. + ### `allowed_prefixes` Type: `list` From c162290aee3522486dd122aedc5bf27c49cc1fd1 Mon Sep 17 00:00:00 2001 From: catfish Date: Wed, 13 Aug 2025 21:20:14 +0800 Subject: [PATCH 3/4] refactor: use None instead of 0 for message_length_limit and update related logic, types, and tests --- commitizen/commands/check.py | 17 ++++++----------- commitizen/commands/commit.py | 11 ++++++----- commitizen/defaults.py | 4 ++-- tests/commands/test_check_command.py | 2 +- tests/commands/test_commit_command.py | 2 +- tests/test_conf.py | 4 ++-- 6 files changed, 18 insertions(+), 22 deletions(-) diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index 58acb187bb..f9c5b9994b 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -19,7 +19,7 @@ class CheckArgs(TypedDict, total=False): commit_msg: str rev_range: str allow_abort: bool - message_length_limit: int + message_length_limit: int | None allowed_prefixes: list[str] message: str @@ -42,12 +42,7 @@ def __init__(self, config: BaseConfig, arguments: CheckArgs, *args: object) -> N arguments.get("allow_abort", config.settings["allow_abort"]) ) - # Use command line argument if provided, otherwise use config setting - cmd_length_limit = arguments.get("message_length_limit") - if cmd_length_limit is None: - self.max_msg_length = config.settings.get("message_length_limit", 0) - else: - self.max_msg_length = cmd_length_limit + self.max_msg_length = arguments.get("message_length_limit", config.settings.get("message_length_limit", None)) # we need to distinguish between None and [], which is a valid value allowed_prefixes = arguments.get("allowed_prefixes") @@ -90,7 +85,7 @@ def __call__(self) -> None: invalid_msgs_content = "\n".join( f'commit "{commit.rev}": "{commit.message}"' for commit in commits - if not self._validate_commit_message(commit.message, pattern) + 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 @@ -150,7 +145,7 @@ def _filter_comments(msg: str) -> str: return "\n".join(lines) def _validate_commit_message( - self, commit_msg: str, pattern: re.Pattern[str] + self, commit_msg: str, pattern: re.Pattern[str], commit_hash: str ) -> bool: if not commit_msg: return self.allow_abort @@ -158,13 +153,13 @@ def _validate_commit_message( if any(map(commit_msg.startswith, self.allowed_prefixes)): return True - if self.max_msg_length: + 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_msg}"\n' + f'commit "{commit_hash}": "{commit_msg}"\n' f"message length limit: {self.max_msg_length} (actual: {msg_len})" ) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 0ce9180d7c..3275ef4d7e 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -33,7 +33,7 @@ class CommitArgs(TypedDict, total=False): dry_run: bool edit: bool extra_cli_args: str - message_length_limit: int + message_length_limit: int | None no_retry: bool signoff: bool write_message_to_file: Path | None @@ -82,11 +82,12 @@ def _prompt_commit_questions(self) -> str: message = cz.message(answers) message_len = len(message.partition("\n")[0].strip()) - message_length_limit = self.arguments.get("message_length_limit") - if message_length_limit is None: - message_length_limit = self.config.settings.get("message_length_limit", 0) + message_length_limit = self.arguments.get( + "message_length_limit", + self.config.settings.get("message_length_limit", None), + ) - if 0 < message_length_limit < message_len: + if message_length_limit is not None and message_len > message_length_limit: raise CommitMessageLengthExceededError( f"Length of commit message exceeds limit ({message_len}/{message_length_limit})" ) diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 792cc770a6..5f8a6e720e 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -46,7 +46,7 @@ class Settings(TypedDict, total=False): ignored_tag_formats: Sequence[str] legacy_tag_formats: Sequence[str] major_version_zero: bool - message_length_limit: int + message_length_limit: int | None name: str post_bump_hooks: list[str] | None pre_bump_hooks: list[str] | None @@ -109,7 +109,7 @@ class Settings(TypedDict, total=False): "always_signoff": False, "template": None, # default provided by plugin "extras": {}, - "message_length_limit": 0, # 0 for no limit + "message_length_limit": None, # None for no limit } MAJOR = "MAJOR" diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py index 1a325d7f1b..bc5487cabe 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -507,7 +507,7 @@ def test_check_command_cli_overrides_config_message_length_limit( success_mock.reset_mock() check_cmd = commands.Check( config=config, - arguments={"message": message, "message_length_limit": 0}, + arguments={"message": message, "message_length_limit": None}, ) check_cmd() diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 6a247f7783..09169f8696 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -611,5 +611,5 @@ def test_commit_command_cli_overrides_config_message_length_limit( success_mock.assert_called_once() success_mock.reset_mock() - commands.Commit(config, {"message_length_limit": 0})() + commands.Commit(config, {"message_length_limit": None})() success_mock.assert_called_once() diff --git a/tests/test_conf.py b/tests/test_conf.py index 3afd37dc3a..e8c0b17c19 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -95,7 +95,7 @@ "always_signoff": False, "template": None, "extras": {}, - "message_length_limit": 0, + "message_length_limit": None, } _new_settings: dict[str, Any] = { @@ -127,7 +127,7 @@ "always_signoff": False, "template": None, "extras": {}, - "message_length_limit": 0, + "message_length_limit": None, } From af6aa622bfd2bf6b9e1f7ef4347b994ba2f8d356 Mon Sep 17 00:00:00 2001 From: catfish Date: Wed, 13 Aug 2025 21:39:58 +0800 Subject: [PATCH 4/4] style: fix line length formatting in check.py --- commitizen/commands/check.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index f9c5b9994b..4636390a96 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -42,7 +42,9 @@ def __init__(self, config: BaseConfig, arguments: CheckArgs, *args: object) -> N arguments.get("allow_abort", config.settings["allow_abort"]) ) - self.max_msg_length = arguments.get("message_length_limit", config.settings.get("message_length_limit", None)) + self.max_msg_length = arguments.get( + "message_length_limit", config.settings.get("message_length_limit", None) + ) # we need to distinguish between None and [], which is a valid value allowed_prefixes = arguments.get("allowed_prefixes")