From aa15fda8942b008e63542129fe082948b1b9fad9 Mon Sep 17 00:00:00 2001 From: Ying-Fang Date: Wed, 4 Feb 2026 16:20:18 -0600 Subject: [PATCH 1/3] feat(commit): add a tag(--body-length-limit) and a function for command commit --- commitizen/cli.py | 5 ++++ commitizen/commands/commit.py | 26 +++++++++++++++++++ commitizen/defaults.py | 2 ++ ...n_when_use_help_option_py_3_10_commit_.txt | 7 ++++- ...n_when_use_help_option_py_3_11_commit_.txt | 7 ++++- ...n_when_use_help_option_py_3_12_commit_.txt | 7 ++++- ...n_when_use_help_option_py_3_13_commit_.txt | 7 ++++- ...n_when_use_help_option_py_3_14_commit_.txt | 7 ++++- tests/test_conf.py | 2 ++ 9 files changed, 65 insertions(+), 5 deletions(-) diff --git a/commitizen/cli.py b/commitizen/cli.py index 79988fb5cb..fac79af3af 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -164,6 +164,11 @@ def __call__( "type": int, "help": "Set the length limit of the commit message; 0 for no limit.", }, + { + "name": ["--body-length-limit"], + "type": int, + "help": "Set the length limit of the commit body. Commit message in body will be rewrapped to this length; 0 for no limit.", + }, { "name": ["--"], "action": "store_true", diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 5776af4201..06120bad59 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -5,6 +5,7 @@ import shutil import subprocess import tempfile +import textwrap from typing import TYPE_CHECKING, TypedDict import questionary @@ -37,6 +38,7 @@ class CommitArgs(TypedDict, total=False): edit: bool extra_cli_args: str message_length_limit: int + body_length_limit: int no_retry: bool signoff: bool write_message_to_file: Path | None @@ -84,6 +86,7 @@ def _get_message_by_prompt_commit_questions(self) -> str: message = self.cz.message(answers) self._validate_subject_length(message) + message = self._rewrap_body(message) return message def _validate_subject_length(self, message: str) -> None: @@ -102,6 +105,29 @@ def _validate_subject_length(self, message: str) -> None: f"Length of commit message exceeds limit ({len(subject)}/{message_length_limit}), subject: '{subject}'" ) + def _rewrap_body(self, message: str) -> str: + body_length_limit = self.arguments.get( + "body_length_limit", self.config.settings.get("body_length_limit", 0) + ) + # By the contract, body_length_limit is set to 0 for no limit + if ( + body_length_limit is None or body_length_limit <= 0 + ): # do nothing for no limit + return message + + message_parts = message.split("\n", 2) + if len(message_parts) < 3: + return message + + # First line is subject, second is blank line, rest is body + subject = message_parts[0] + blank_line = message_parts[1] + body = message_parts[2].strip() + wrapped_body = textwrap.fill( + body, width=body_length_limit, replace_whitespace=False + ) + return f"{subject}\n{blank_line}\n{wrapped_body}" + def manual_edit(self, message: str) -> str: editor = git.get_core_editor() if editor is None: diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 4865ccc188..94bd166696 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -49,6 +49,7 @@ class Settings(TypedDict, total=False): legacy_tag_formats: Sequence[str] major_version_zero: bool message_length_limit: int + body_length_limit: int name: str post_bump_hooks: list[str] | None pre_bump_hooks: list[str] | None @@ -115,6 +116,7 @@ class Settings(TypedDict, total=False): "extras": {}, "breaking_change_exclamation_in_title": False, "message_length_limit": 0, # 0 for no limit + "body_length_limit": 0, # 0 for no limit } MAJOR = "MAJOR" diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_commit_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_commit_.txt index bd256ccf8c..7cc8366eea 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_commit_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_commit_.txt @@ -1,6 +1,7 @@ usage: cz commit [-h] [--retry] [--no-retry] [--dry-run] [--write-message-to-file FILE_PATH] [-s] [-a] [-e] - [-l MESSAGE_LENGTH_LIMIT] [--] + [-l MESSAGE_LENGTH_LIMIT] + [--body-length-limit BODY_LENGTH_LIMIT] [--] Create new commit @@ -22,4 +23,8 @@ options: -l MESSAGE_LENGTH_LIMIT, --message-length-limit MESSAGE_LENGTH_LIMIT Set the length limit of the commit message; 0 for no limit. + --body-length-limit BODY_LENGTH_LIMIT + Set the length limit of the commit body. Commit + message in body will be rewrapped to this length; 0 + for no limit. -- Positional arguments separator (recommended). diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_commit_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_commit_.txt index bd256ccf8c..7cc8366eea 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_commit_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_commit_.txt @@ -1,6 +1,7 @@ usage: cz commit [-h] [--retry] [--no-retry] [--dry-run] [--write-message-to-file FILE_PATH] [-s] [-a] [-e] - [-l MESSAGE_LENGTH_LIMIT] [--] + [-l MESSAGE_LENGTH_LIMIT] + [--body-length-limit BODY_LENGTH_LIMIT] [--] Create new commit @@ -22,4 +23,8 @@ options: -l MESSAGE_LENGTH_LIMIT, --message-length-limit MESSAGE_LENGTH_LIMIT Set the length limit of the commit message; 0 for no limit. + --body-length-limit BODY_LENGTH_LIMIT + Set the length limit of the commit body. Commit + message in body will be rewrapped to this length; 0 + for no limit. -- Positional arguments separator (recommended). diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_commit_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_commit_.txt index bd256ccf8c..7cc8366eea 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_commit_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_commit_.txt @@ -1,6 +1,7 @@ usage: cz commit [-h] [--retry] [--no-retry] [--dry-run] [--write-message-to-file FILE_PATH] [-s] [-a] [-e] - [-l MESSAGE_LENGTH_LIMIT] [--] + [-l MESSAGE_LENGTH_LIMIT] + [--body-length-limit BODY_LENGTH_LIMIT] [--] Create new commit @@ -22,4 +23,8 @@ options: -l MESSAGE_LENGTH_LIMIT, --message-length-limit MESSAGE_LENGTH_LIMIT Set the length limit of the commit message; 0 for no limit. + --body-length-limit BODY_LENGTH_LIMIT + Set the length limit of the commit body. Commit + message in body will be rewrapped to this length; 0 + for no limit. -- Positional arguments separator (recommended). diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_commit_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_commit_.txt index cbd5780f6d..df3df7c540 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_commit_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_commit_.txt @@ -1,6 +1,7 @@ usage: cz commit [-h] [--retry] [--no-retry] [--dry-run] [--write-message-to-file FILE_PATH] [-s] [-a] [-e] - [-l MESSAGE_LENGTH_LIMIT] [--] + [-l MESSAGE_LENGTH_LIMIT] + [--body-length-limit BODY_LENGTH_LIMIT] [--] Create new commit @@ -22,4 +23,8 @@ options: -l, --message-length-limit MESSAGE_LENGTH_LIMIT Set the length limit of the commit message; 0 for no limit. + --body-length-limit BODY_LENGTH_LIMIT + Set the length limit of the commit body. Commit + message in body will be rewrapped to this length; 0 + for no limit. -- Positional arguments separator (recommended). diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_commit_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_commit_.txt index cbd5780f6d..df3df7c540 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_commit_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_commit_.txt @@ -1,6 +1,7 @@ usage: cz commit [-h] [--retry] [--no-retry] [--dry-run] [--write-message-to-file FILE_PATH] [-s] [-a] [-e] - [-l MESSAGE_LENGTH_LIMIT] [--] + [-l MESSAGE_LENGTH_LIMIT] + [--body-length-limit BODY_LENGTH_LIMIT] [--] Create new commit @@ -22,4 +23,8 @@ options: -l, --message-length-limit MESSAGE_LENGTH_LIMIT Set the length limit of the commit message; 0 for no limit. + --body-length-limit BODY_LENGTH_LIMIT + Set the length limit of the commit body. Commit + message in body will be rewrapped to this length; 0 + for no limit. -- Positional arguments separator (recommended). diff --git a/tests/test_conf.py b/tests/test_conf.py index f1ff76ff88..9d69b63042 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -112,6 +112,7 @@ "extras": {}, "breaking_change_exclamation_in_title": False, "message_length_limit": 0, + "body_length_limit": 0, } _new_settings: dict[str, Any] = { @@ -152,6 +153,7 @@ "extras": {}, "breaking_change_exclamation_in_title": False, "message_length_limit": 0, + "body_length_limit": 0, } From 1f74bb2e74d7b040aa57dba4461aca0b9948da97 Mon Sep 17 00:00:00 2001 From: Ying-Fang Date: Wed, 4 Feb 2026 17:47:57 -0600 Subject: [PATCH 2/3] test(commit): add some test for commit --body-length-limit flag --- commitizen/commands/commit.py | 8 +- tests/commands/test_commit_command.py | 227 ++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 3 deletions(-) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 06120bad59..2c541c8682 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -123,9 +123,11 @@ def _rewrap_body(self, message: str) -> str: subject = message_parts[0] blank_line = message_parts[1] body = message_parts[2].strip() - wrapped_body = textwrap.fill( - body, width=body_length_limit, replace_whitespace=False - ) + body_lines = body.split("\n") + wrapped_body_lines = [] + for line in body_lines: + wrapped_body_lines.append(textwrap.fill(line, width=body_length_limit)) + wrapped_body = "\n".join(wrapped_body_lines) return f"{subject}\n{blank_line}\n{wrapped_body}" def manual_edit(self, message: str) -> str: diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index c80a13823d..4fb3d048a3 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -365,3 +365,230 @@ def test_commit_command_with_config_message_length_limit( success_mock.reset_mock() commands.Commit(config, {"message_length_limit": 0})() success_mock.assert_called_once() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_body_length_limit_wrapping( + config, success_mock: MockType, mocker: MockFixture +): + """Test that long body lines are automatically wrapped to the specified limit.""" + mocker.patch( + "questionary.prompt", + return_value={ + "prefix": "feat", + "subject": "add feature", + "scope": "", + "is_breaking_change": False, + "body": "This is a very long line that exceeds 72 characters and should be automatically wrapped by the system to fit within the limit", + "footer": "", + }, + ) + + commit_mock = mocker.patch( + "commitizen.git.commit", return_value=cmd.Command("success", "", b"", b"", 0) + ) + + # Execute with body_length_limit + commands.Commit(config, {"body_length_limit": 72})() + success_mock.assert_called_once() + + # Verify wrapping occurred + committed_message = commit_mock.call_args[0][0] + lines = committed_message.split("\n") + assert lines[0] == "feat: add feature" + assert lines[1] == "" + body_lines = lines[2:] + for line in body_lines: + if line.strip(): + assert len(line) <= 72, ( + f"Line exceeds 72 chars: '{line}' ({len(line)} chars)" + ) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_body_length_limit_preserves_line_breaks( + config, success_mock: MockType, mocker: MockFixture +): + """Test that intentional line breaks (from | character) are preserved.""" + # Simulate what happens after multiple_line_breaker processes "line1 | line2 | line3" + mocker.patch( + "questionary.prompt", + return_value={ + "prefix": "feat", + "subject": "add feature", + "scope": "", + "is_breaking_change": False, + "body": "Line1 that is very long and exceeds the limit\nLine2 that is very long and exceeds the limit\nLine3 that is very long and exceeds the limit", + "footer": "", + }, + ) + + commit_mock = mocker.patch( + "commitizen.git.commit", return_value=cmd.Command("success", "", b"", b"", 0) + ) + + commands.Commit(config, {"body_length_limit": 45})() + success_mock.assert_called_once() + + committed_message = commit_mock.call_args[0][0] + lines = committed_message.split("\n") + + # Should have a subject, a blank line + assert lines[0] == "feat: add feature" + assert lines[1] == "" + # Each original line should be wrapped separately, preserving the line breaks + body_lines = lines[2:] + # All lines should be <= 45 chars + for line in body_lines: + if line.strip(): + assert len(line) == 45, ( + f"Line's length is not 45 chars: '{line}' ({len(line)} chars)" + ) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_body_length_limit_disabled( + config, success_mock: MockType, mocker: MockFixture +): + """Test that body_length_limit = 0 disables wrapping.""" + long_body = "This is a very long line that exceeds 72 characters and should NOT be wrapped when body_length_limit is set to 0" + + mocker.patch( + "questionary.prompt", + return_value={ + "prefix": "feat", + "subject": "add feature", + "scope": "", + "is_breaking_change": False, + "body": long_body, + "footer": "", + }, + ) + + commit_mock = mocker.patch( + "commitizen.git.commit", return_value=cmd.Command("success", "", b"", b"", 0) + ) + + # Execute with body_length_limit = 0 (disabled) + commands.Commit(config, {"body_length_limit": 0})() + + success_mock.assert_called_once() + + # Get the actual commit message + committed_message = commit_mock.call_args[0][0] + + # Verify the body was NOT wrapped (should contain the original long line) + assert long_body in committed_message, "Body should not be wrapped when limit is 0" + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_body_length_limit_from_config( + config, success_mock: MockType, mocker: MockFixture +): + """Test that body_length_limit can be set via config.""" + mocker.patch( + "questionary.prompt", + return_value={ + "prefix": "feat", + "subject": "add feature", + "scope": "", + "is_breaking_change": False, + "body": "This is a very long line that exceeds 50 characters and should be wrapped", + "footer": "", + }, + ) + + commit_mock = mocker.patch( + "commitizen.git.commit", return_value=cmd.Command("success", "", b"", b"", 0) + ) + + # Set body_length_limit in config + config.settings["body_length_limit"] = 50 + + commands.Commit(config, {})() + + success_mock.assert_called_once() + + # Get the actual commit message + committed_message = commit_mock.call_args[0][0] + + # Verify all body lines are within the limit + lines = committed_message.split("\n") + body_lines = lines[2:] + for line in body_lines: + if line.strip(): + assert len(line) <= 50, ( + f"Line exceeds 50 chars: '{line}' ({len(line)} chars)" + ) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_body_length_limit_cli_overrides_config( + config, success_mock: MockType, mocker: MockFixture +): + """Test that CLI argument overrides config setting.""" + mocker.patch( + "questionary.prompt", + return_value={ + "prefix": "feat", + "subject": "add feature", + "scope": "", + "is_breaking_change": False, + "body": "This is a line that is longer than 40 characters but shorter than 80 characters", + "footer": "", + }, + ) + + commit_mock = mocker.patch( + "commitizen.git.commit", return_value=cmd.Command("success", "", b"", b"", 0) + ) + + # Set config to 40 (would wrap) + config.settings["body_length_limit"] = 40 + + # Override with CLI argument to 0 (should NOT wrap) + commands.Commit(config, {"body_length_limit": 0})() + + success_mock.assert_called_once() + + # Get the actual commit message + committed_message = commit_mock.call_args[0][0] + + # The line should NOT be wrapped (CLI override to 0 disables wrapping) + assert ( + "This is a line that is longer than 40 characters but shorter than 80 characters" + in committed_message + ) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_body_length_limit_no_body( + config, success_mock: MockType, mocker: MockFixture +): + """Test that commits without body work correctly with body_length_limit set.""" + mocker.patch( + "questionary.prompt", + return_value={ + "prefix": "feat", + "subject": "add feature", + "scope": "", + "is_breaking_change": False, + "body": "", # No body + "footer": "", + }, + ) + + commit_mock = mocker.patch( + "commitizen.git.commit", return_value=cmd.Command("success", "", b"", b"", 0) + ) + + # Execute commit with body_length_limit (should not crash) + commands.Commit(config, {"body_length_limit": 72})() + + success_mock.assert_called_once() + + # Get the actual commit message + committed_message = commit_mock.call_args[0][0] + + # Should just be the subject line + assert committed_message.strip() == "feat: add feature" From 49bb71baa7f21f484efb704743ea02e878abcfee Mon Sep 17 00:00:00 2001 From: Finn Date: Thu, 5 Feb 2026 15:32:22 -0600 Subject: [PATCH 3/3] Apply suggestion from @bearomorphism Co-authored-by: Tim Hsiung --- commitizen/commands/commit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 2c541c8682..7ee3f19ad7 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -115,7 +115,7 @@ def _rewrap_body(self, message: str) -> str: ): # do nothing for no limit return message - message_parts = message.split("\n", 2) + lines = message.split("\n") if len(message_parts) < 3: return message