From 092ec64e004e20b58d0318e9e83afd52b9a42f4f Mon Sep 17 00:00:00 2001 From: TTW Date: Wed, 18 Mar 2026 13:57:04 +0800 Subject: [PATCH 01/11] fix(commit): honor message length limit from cli and config CLI > config > default (0) for not limit --- commitizen/commands/commit.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 6668c0d65..f561cff22 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -54,6 +54,13 @@ def __init__(self, config: BaseConfig, arguments: CommitArgs) -> None: self.arguments = arguments self.backup_file_path = get_backup_file_path() + message_length_limit = arguments.get("message_length_limit") + self.message_length_limit: int = ( + message_length_limit + if message_length_limit is not None + else config.settings["message_length_limit"] + ) + def _read_backup_message(self) -> str | None: # Check the commit backup file exists if not self.backup_file_path.is_file(): @@ -85,19 +92,14 @@ def _get_message_by_prompt_commit_questions(self) -> str: return message def _validate_subject_length(self, message: str) -> None: - message_length_limit = self.arguments.get( - "message_length_limit", self.config.settings.get("message_length_limit", 0) - ) # By the contract, message_length_limit is set to 0 for no limit - if ( - message_length_limit is None or message_length_limit <= 0 - ): # do nothing for no limit + if self.message_length_limit <= 0: return subject = message.partition("\n")[0].strip() - if len(subject) > message_length_limit: + if len(subject) > self.message_length_limit: raise CommitMessageLengthExceededError( - f"Length of commit message exceeds limit ({len(subject)}/{message_length_limit}), subject: '{subject}'" + f"Length of commit message exceeds limit ({len(subject)}/{self.message_length_limit}), subject: '{subject}'" ) def manual_edit(self, message: str) -> str: From 2be6bcb93de598b23dda71242e49abd8b36efb5e Mon Sep 17 00:00:00 2001 From: TTW Date: Wed, 18 Mar 2026 13:58:27 +0800 Subject: [PATCH 02/11] fix(check): honor message length limit from cli and config CLI > config > default (0) for not limit --- commitizen/commands/check.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index ab5e671d6..9449a7082 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -46,8 +46,12 @@ def __init__(self, config: BaseConfig, arguments: CheckArgs, *args: object) -> N ) self.use_default_range = bool(arguments.get("use_default_range")) - self.max_msg_length = arguments.get( - "message_length_limit", config.settings.get("message_length_limit", 0) + + message_length_limit = arguments.get("message_length_limit") + self.message_length_limit: int = ( + message_length_limit + if message_length_limit is not None + else config.settings["message_length_limit"] ) # we need to distinguish between None and [], which is a valid value @@ -100,7 +104,7 @@ def __call__(self) -> None: pattern=pattern, allow_abort=self.allow_abort, allowed_prefixes=self.allowed_prefixes, - max_msg_length=self.max_msg_length, + max_msg_length=self.message_length_limit, commit_hash=commit.rev, ) ).is_valid From 6d7bba1cf77dc3e762642e5e4d1547d7f948b5e8 Mon Sep 17 00:00:00 2001 From: TTW Date: Wed, 18 Mar 2026 15:13:24 +0800 Subject: [PATCH 03/11] test(commit): add coverage for message length limit precedence --- tests/commands/test_commit_command.py | 64 ++++++++++++++++++++------- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 2322cb3cb..034ba5872 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -336,34 +336,66 @@ def test_commit_when_nothing_added_to_commit(config, mocker: MockFixture, out): error_mock.assert_called_once_with(out) -@pytest.mark.usefixtures("staging_is_clean", "commit_mock") -def test_commit_command_with_config_message_length_limit( - config, success_mock: MockType, prompt_mock_feat: MockType -): +def _commit_first_line_len(prompt_mock_feat: MockType) -> int: prefix = prompt_mock_feat.return_value["prefix"] subject = prompt_mock_feat.return_value["subject"] - message_length = len(f"{prefix}: {subject}") + scope = prompt_mock_feat.return_value["scope"] + + formatted_scope = f"({scope})" if scope else "" + first_line = f"{prefix}{formatted_scope}: {subject}" + return len(first_line) - commands.Commit(config, {"message_length_limit": message_length})() + +@pytest.mark.usefixtures("staging_is_clean", "commit_mock", "prompt_mock_feat") +def test_commit_message_length_cli_at_limit_succeeds( + config, success_mock: MockType, prompt_mock_feat: MockType +): + message_len = _commit_first_line_len(prompt_mock_feat) + commands.Commit(config, {"message_length_limit": message_len})() success_mock.assert_called_once() + +@pytest.mark.usefixtures("staging_is_clean", "commit_mock", "prompt_mock_feat") +def test_commit_message_length_cli_below_limit_raises( + config, prompt_mock_feat: MockType +): + message_len = _commit_first_line_len(prompt_mock_feat) with pytest.raises(CommitMessageLengthExceededError): - commands.Commit(config, {"message_length_limit": message_length - 1})() + commands.Commit(config, {"message_length_limit": message_len - 1})() - config.settings["message_length_limit"] = message_length - success_mock.reset_mock() - commands.Commit(config, {})() + +@pytest.mark.usefixtures("staging_is_clean", "commit_mock", "prompt_mock_feat") +def test_commit_message_length_uses_config_when_cli_unset( + config, success_mock: MockType, prompt_mock_feat: MockType +): + config.settings["message_length_limit"] = _commit_first_line_len(prompt_mock_feat) + commands.Commit(config, {"message_length_limit": None})() success_mock.assert_called_once() - config.settings["message_length_limit"] = message_length - 1 + +@pytest.mark.usefixtures("staging_is_clean", "commit_mock", "prompt_mock_feat") +def test_commit_message_length_config_exceeded_when_cli_unset( + config, prompt_mock_feat: MockType +): + config.settings["message_length_limit"] = _commit_first_line_len(prompt_mock_feat) - 1 with pytest.raises(CommitMessageLengthExceededError): - commands.Commit(config, {})() + commands.Commit(config, {"message_length_limit": None})() + - # Test config message length limit is overridden by CLI argument - success_mock.reset_mock() - commands.Commit(config, {"message_length_limit": message_length})() +@pytest.mark.usefixtures("staging_is_clean", "commit_mock", "prompt_mock_feat") +def test_commit_message_length_cli_overrides_stricter_config( + config, success_mock: MockType, prompt_mock_feat: MockType +): + message_len = _commit_first_line_len(prompt_mock_feat) + config.settings["message_length_limit"] = message_len - 1 + commands.Commit(config, {"message_length_limit": message_len})() success_mock.assert_called_once() - success_mock.reset_mock() + +@pytest.mark.usefixtures("staging_is_clean", "commit_mock", "prompt_mock_feat") +def test_commit_message_length_cli_zero_disables_limit( + config, success_mock: MockType, prompt_mock_feat: MockType +): + config.settings["message_length_limit"] = _commit_first_line_len(prompt_mock_feat) - 1 commands.Commit(config, {"message_length_limit": 0})() success_mock.assert_called_once() From b0deaf64afa0ff87054b56dbd847f17f2fe88cce Mon Sep 17 00:00:00 2001 From: TTW Date: Wed, 18 Mar 2026 16:16:56 +0800 Subject: [PATCH 04/11] test(check): add coverage for message length limit precedence --- tests/commands/test_check_command.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py index f3f860313..072ed7330 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -351,23 +351,25 @@ def test_check_command_with_amend_prefix_default(config, success_mock): success_mock.assert_called_once() -def test_check_command_with_config_message_length_limit(config, success_mock): +def test_check_command_with_config_message_length_limit_and_cli_none(config, success_mock: MockType): message = "fix(scope): some commit message" config.settings["message_length_limit"] = len(message) + 1 commands.Check( config=config, - arguments={"message": message}, + arguments={"message": message, "message_length_limit": None}, )() success_mock.assert_called_once() -def test_check_command_with_config_message_length_limit_exceeded(config): +def test_check_command_with_config_message_length_limit_exceeded_and_cli_none( + config, +): message = "fix(scope): some commit message" config.settings["message_length_limit"] = len(message) - 1 with pytest.raises(CommitMessageLengthExceededError): commands.Check( config=config, - arguments={"message": message}, + arguments={"message": message, "message_length_limit": None}, )() @@ -376,7 +378,7 @@ def test_check_command_cli_overrides_config_message_length_limit( ): message = "fix(scope): some commit message" config.settings["message_length_limit"] = len(message) - 1 - for message_length_limit in [len(message) + 1, 0]: + for message_length_limit in [len(message), 0]: success_mock.reset_mock() commands.Check( config=config, From e61f70cca2006d26a6d3bf26eb35e437a314f96c Mon Sep 17 00:00:00 2001 From: TTW Date: Wed, 18 Mar 2026 16:21:02 +0800 Subject: [PATCH 05/11] test(cli-config): add integration tests for message length limit config --- tests/test_cli_config_integration.py | 87 ++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/test_cli_config_integration.py diff --git a/tests/test_cli_config_integration.py b/tests/test_cli_config_integration.py new file mode 100644 index 000000000..156c59f1a --- /dev/null +++ b/tests/test_cli_config_integration.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from commitizen.exceptions import CommitMessageLengthExceededError, DryRunExit +from tests.utils import UtilFixture + + +def _write_pyproject_with_message_length_limit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, message_length_limit: int +) -> None: + (tmp_path / "pyproject.toml").write_text( + "[tool.commitizen]\n" + 'name = "cz_conventional_commits"\n' + f"message_length_limit = {message_length_limit}\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + +def _mock_commit_prompt(mocker, *, subject: str) -> None: + mocker.patch( + "questionary.prompt", + return_value={ + "prefix": "feat", + "subject": subject, + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + }, + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_cli_check_reads_message_length_limit_from_pyproject( + util: UtilFixture, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + _write_pyproject_with_message_length_limit(tmp_path, monkeypatch, 10) + + long_message_file = tmp_path / "long_message.txt" + long_message_file.write_text("feat: this is definitely too long", encoding="utf-8") + + with pytest.raises(CommitMessageLengthExceededError): + util.run_cli("check", "--commit-msg-file", str(long_message_file)) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_cli_commit_reads_message_length_limit_from_pyproject( + util: UtilFixture, + monkeypatch: pytest.MonkeyPatch, + mocker, + tmp_path: Path, +): + _write_pyproject_with_message_length_limit(tmp_path, monkeypatch, 10) + _mock_commit_prompt(mocker, subject="this is definitely too long") + mocker.patch("commitizen.git.is_staging_clean", return_value=False) + + with pytest.raises(CommitMessageLengthExceededError): + util.run_cli("commit", "--dry-run") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_cli_check_cli_overrides_message_length_limit_from_pyproject( + util: UtilFixture, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + _write_pyproject_with_message_length_limit(tmp_path, monkeypatch, 10) + + util.run_cli("check", "-l", "0", "--message", "feat: this is definitely too long") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_cli_commit_cli_overrides_message_length_limit_from_pyproject( + util: UtilFixture, + monkeypatch: pytest.MonkeyPatch, + mocker, + tmp_path: Path, +): + _write_pyproject_with_message_length_limit(tmp_path, monkeypatch, 10) + _mock_commit_prompt(mocker, subject="this is definitely too long") + mocker.patch("commitizen.git.is_staging_clean", return_value=False) + + with pytest.raises(DryRunExit): + util.run_cli("commit", "--dry-run", "-l", "100") + From e6e74751f222dfe01ff3174ea4f29e28e9ee68f9 Mon Sep 17 00:00:00 2001 From: TTW Date: Wed, 18 Mar 2026 16:35:12 +0800 Subject: [PATCH 06/11] fix(types): allow None for message_length_limit cli arg --- commitizen/commands/check.py | 2 +- commitizen/commands/commit.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index 9449a7082..01faf0eb1 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -21,7 +21,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 use_default_range: bool diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index f561cff22..4a0bd5886 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -35,7 +35,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 From f5c4b4880bd3204d75ee1ba702d784bcf16c0624 Mon Sep 17 00:00:00 2001 From: TTW Date: Wed, 18 Mar 2026 16:35:47 +0800 Subject: [PATCH 07/11] refactor(test): apply type-check-only imports --- tests/commands/test_check_command.py | 4 +++- tests/commands/test_commit_command.py | 8 ++++++-- tests/test_cli_config_integration.py | 9 ++++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py index 072ed7330..5abd0688a 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -351,7 +351,9 @@ def test_check_command_with_amend_prefix_default(config, success_mock): success_mock.assert_called_once() -def test_check_command_with_config_message_length_limit_and_cli_none(config, success_mock: MockType): +def test_check_command_with_config_message_length_limit_and_cli_none( + config, success_mock: MockType +): message = "fix(scope): some commit message" config.settings["message_length_limit"] = len(message) + 1 commands.Check( diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 034ba5872..497b57607 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -377,7 +377,9 @@ def test_commit_message_length_uses_config_when_cli_unset( def test_commit_message_length_config_exceeded_when_cli_unset( config, prompt_mock_feat: MockType ): - config.settings["message_length_limit"] = _commit_first_line_len(prompt_mock_feat) - 1 + config.settings["message_length_limit"] = ( + _commit_first_line_len(prompt_mock_feat) - 1 + ) with pytest.raises(CommitMessageLengthExceededError): commands.Commit(config, {"message_length_limit": None})() @@ -396,6 +398,8 @@ def test_commit_message_length_cli_overrides_stricter_config( def test_commit_message_length_cli_zero_disables_limit( config, success_mock: MockType, prompt_mock_feat: MockType ): - config.settings["message_length_limit"] = _commit_first_line_len(prompt_mock_feat) - 1 + config.settings["message_length_limit"] = ( + _commit_first_line_len(prompt_mock_feat) - 1 + ) commands.Commit(config, {"message_length_limit": 0})() success_mock.assert_called_once() diff --git a/tests/test_cli_config_integration.py b/tests/test_cli_config_integration.py index 156c59f1a..2406549ba 100644 --- a/tests/test_cli_config_integration.py +++ b/tests/test_cli_config_integration.py @@ -1,11 +1,15 @@ from __future__ import annotations -from pathlib import Path +from typing import TYPE_CHECKING import pytest from commitizen.exceptions import CommitMessageLengthExceededError, DryRunExit -from tests.utils import UtilFixture + +if TYPE_CHECKING: + from pathlib import Path + + from tests.utils import UtilFixture def _write_pyproject_with_message_length_limit( @@ -84,4 +88,3 @@ def test_cli_commit_cli_overrides_message_length_limit_from_pyproject( with pytest.raises(DryRunExit): util.run_cli("commit", "--dry-run", "-l", "100") - From 5e5778b25b7b6d5e4a647be9e8988068b9406199 Mon Sep 17 00:00:00 2001 From: TTW Date: Fri, 20 Mar 2026 11:14:07 +0800 Subject: [PATCH 08/11] feat(check): validate non-negative message_length_limit --- commitizen/commands/check.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index 01faf0eb1..1764f63b7 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -53,6 +53,10 @@ def __init__(self, config: BaseConfig, arguments: CheckArgs, *args: object) -> N if message_length_limit is not None else config.settings["message_length_limit"] ) + if self.message_length_limit < 0: + raise InvalidCommandArgumentError( + "message_length_limit must be a non-negative integer" + ) # we need to distinguish between None and [], which is a valid value allowed_prefixes = arguments.get("allowed_prefixes") From e626291e10954652c9d77f89372694b8f8b9e82f Mon Sep 17 00:00:00 2001 From: TTW Date: Fri, 20 Mar 2026 11:14:30 +0800 Subject: [PATCH 09/11] feat(commit): validate non-negative message_length_limit --- commitizen/commands/commit.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 4a0bd5886..05fa7448c 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -18,6 +18,7 @@ CommitMessageLengthExceededError, CustomError, DryRunExit, + InvalidCommandArgumentError, NoAnswersError, NoCommitBackupError, NotAGitProjectError, @@ -60,6 +61,10 @@ def __init__(self, config: BaseConfig, arguments: CommitArgs) -> None: if message_length_limit is not None else config.settings["message_length_limit"] ) + if self.message_length_limit < 0: + raise InvalidCommandArgumentError( + "message_length_limit must be a non-negative integer" + ) def _read_backup_message(self) -> str | None: # Check the commit backup file exists @@ -93,7 +98,7 @@ def _get_message_by_prompt_commit_questions(self) -> str: def _validate_subject_length(self, message: str) -> None: # By the contract, message_length_limit is set to 0 for no limit - if self.message_length_limit <= 0: + if self.message_length_limit == 0: return subject = message.partition("\n")[0].strip() From 67db6262ca88a6f2be44a372c5902c0dea52f9dd Mon Sep 17 00:00:00 2001 From: TTW Date: Fri, 20 Mar 2026 11:19:17 +0800 Subject: [PATCH 10/11] test(message_length_limit): validate negative message_length_limit --- tests/commands/test_check_command.py | 23 +++++++++++++++++++++++ tests/commands/test_commit_command.py | 12 ++++++++++++ 2 files changed, 35 insertions(+) diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py index 5abd0688a..00d9353db 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -392,6 +392,29 @@ def test_check_command_cli_overrides_config_message_length_limit( success_mock.assert_called_once() +def test_check_command_with_negative_cli_message_length_limit_raises(config): + with pytest.raises(InvalidCommandArgumentError): + commands.Check( + config=config, + arguments={ + "message": "fix(scope): some commit message", + "message_length_limit": -1, + }, + ) + + +def test_check_command_with_negative_config_message_length_limit_raises(config): + config.settings["message_length_limit"] = -1 + with pytest.raises(InvalidCommandArgumentError): + commands.Check( + config=config, + arguments={ + "message": "fix(scope): some commit message", + "message_length_limit": None, + }, + ) + + class ValidationCz(BaseCommitizen): def questions(self) -> list[CzQuestion]: return [ diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 497b57607..e7a3ded2a 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -12,6 +12,7 @@ CommitMessageLengthExceededError, CustomError, DryRunExit, + InvalidCommandArgumentError, NoAnswersError, NoCommitBackupError, NotAGitProjectError, @@ -403,3 +404,14 @@ def test_commit_message_length_cli_zero_disables_limit( ) commands.Commit(config, {"message_length_limit": 0})() success_mock.assert_called_once() + + +def test_commit_message_length_cli_negative_raises(config): + with pytest.raises(InvalidCommandArgumentError): + commands.Commit(config, {"message_length_limit": -1}) + + +def test_commit_message_length_config_negative_raises_when_cli_unset(config): + config.settings["message_length_limit"] = -1 + with pytest.raises(InvalidCommandArgumentError): + commands.Commit(config, {"message_length_limit": None}) From f2ac1c1604ee2da9cb4b79f91810aa4e8ac9f5b9 Mon Sep 17 00:00:00 2001 From: TTW Date: Fri, 20 Mar 2026 11:24:00 +0800 Subject: [PATCH 11/11] docs(message_length_limit): document non-negative limit --- docs/config/check.md | 1 + docs/config/commit.md | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/docs/config/check.md b/docs/config/check.md index 2c8dda27b..d65df1657 100644 --- a/docs/config/check.md +++ b/docs/config/check.md @@ -22,6 +22,7 @@ List of prefixes that commitizen ignores when verifying messages. - Default: `0` (no limit) Maximum length of the commit message. Setting it to `0` disables the length limit. +This value must be a non-negative integer (`>= 0`). !!! note This option can be overridden by the `-l/--message-length-limit` command line argument. diff --git a/docs/config/commit.md b/docs/config/commit.md index 72fdff947..551f6b82a 100644 --- a/docs/config/commit.md +++ b/docs/config/commit.md @@ -24,3 +24,14 @@ Sets the character encoding to be used when parsing commit messages. - Default: `False` Retries failed commit when running `cz commit`. + +## `message_length_limit` + +- Type: `int` +- Default: `0` (no limit) + +Maximum length of the commit message. Setting it to `0` disables the length limit. +This value must be a non-negative integer (`>= 0`). + +!!! note + This option can be overridden by the `-l/--message-length-limit` command line argument.