Skip to content
6 changes: 6 additions & 0 deletions commitizen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ def __call__(
"type": int,
"help": "Set the length limit of the commit message; 0 for no limit.",
},
{
"name": ["--body-length-limit"],
"type": int,
"default": 0,
"help": "Set the length limit of the commit body. Commit message in body will be rewrapped to this length; 0 for no limit.",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can set a default value here?

Copy link
Contributor Author

@yjaw yjaw Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I agree. What’s the difference between setting a default value here and using default_setting in defaults.py?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I think DEFAULT_SETTINGS is not a good design. I am not sure the mechanism of default value in argparse, not sure the exact difference here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally the default should be in DEFAULT_SETTINGS, which can be updated from the .cz.toml file. A default in argparse may override the setting in the file. You can add a test, to check that this is the case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that multiple default setting is undesirable. Should I remove the default setting for this tag from cli.py?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, after a second look, there should not be default values in cli.py because we want cz to use the settings from the configuration file if the value is None in arguments

},
{
"name": ["--"],
"action": "store_true",
Expand Down
22 changes: 22 additions & 0 deletions commitizen/commands/commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import shutil
import subprocess
import tempfile
import textwrap
from itertools import chain
from typing import TYPE_CHECKING, TypedDict

import questionary
Expand Down Expand Up @@ -37,6 +39,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
Expand Down Expand Up @@ -84,6 +87,7 @@ def _get_message_by_prompt_commit_questions(self) -> str:

message = self.cz.message(answers)
self._validate_subject_length(message)
message = self._wrap_body(message)
return message

def _validate_subject_length(self, message: str) -> None:
Expand All @@ -102,6 +106,24 @@ def _validate_subject_length(self, message: str) -> None:
f"Length of commit message exceeds limit ({len(subject)}/{message_length_limit}), subject: '{subject}'"
)

def _wrap_body(self, message: str) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a docstring explaining what this _wrap_body does, and why it was introduced 🙏🏻

body_length_limit = self.arguments.get(
"body_length_limit", self.config.settings.get("body_length_limit", 0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use get here, settings already has a default and you know it exists, if it doesn't this should explode, so it would be better to use:

self.config.settings["body_length_limit"]

This conveys the right meaning: there must be a body_length_limit in the settings, which, based on your code, it's there.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, letting it explode can remind programmers that the behavior is not expected (it should always have a value)?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me rephrase it.
The code you've written guarantees that the value exists, right? Because you've set a default in the settings already.
If that was not the case, there's a bug, and we should surface that bug as soon as possible. Using .get would only hide the bug.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably need to check if similar patterns exists in the code base. I probably introduced some in the past...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got it! Thanks for explaining. In this context, we should raise an error instead of using .get to hide the unexpected behavior.

)
# By the contract, body_length_limit is set to 0 for no limit
if not body_length_limit or body_length_limit <= 0:
return message

lines = message.split("\n")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like lines can be moved below

if len(lines) < 3:
return message

# First line is subject, second is blank line, rest is body
wrapped_body_lines = [
textwrap.wrap(line, width=body_length_limit) for line in lines[2:]
]
return "\n".join(chain(lines[:2], chain.from_iterable(wrapped_body_lines)))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why from_iterable here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since wrapped_body_lines is a list of lists of strings, I flatten it using chain.from_iterable before chaining it to a list of strings.
Do you prefer I manually flatten it? flatten_body = [line for sublist in wrapped_body_lines for line in sublist]

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh I see. I thought textwrap.wrap returns a list of strings.

Then the following is more semantically correct:

wrapped_body_lines = chain.from_iterable(
            textwrap.wrap(line, width=body_length_limit) for line in lines[2:]
        )

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First time using Generator. It's so cool!


def manual_edit(self, message: str) -> str:
editor = git.get_core_editor()
if editor is None:
Expand Down
2 changes: 2 additions & 0 deletions commitizen/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
76 changes: 76 additions & 0 deletions tests/commands/test_commit_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,79 @@ 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")
@pytest.mark.parametrize(
("test_id", "body", "body_length_limit"),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please remove "test_id" and use pytest.param instead

[
# Basic wrapping - long line gets wrapped
(
"wrapping",
"This is a very long line that exceeds 72 characters and should be automatically wrapped by the system to fit within the limit",
72,
),
# Line break preservation - multiple lines with \n
(
"preserves_line_breaks",
"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",
72,
),
# Disabled wrapping - limit = 0
(
"disabled",
"This is a very long line that exceeds 72 characters and should NOT be wrapped when body_length_limit is set to 0",
0,
),
# No body - empty string
(
"no_body",
"",
72,
),
],
)
def test_commit_command_body_length_limit(
test_id,
body,
body_length_limit,
config,
success_mock: MockType,
commit_mock,
mocker: MockFixture,
file_regression,
):
"""Parameterized test for body_length_limit feature with file regression."""
mocker.patch(
"questionary.prompt",
return_value={
"prefix": "feat",
"subject": "add feature",
"scope": "",
"is_breaking_change": False,
"body": body,
"footer": "",
},
Comment on lines +411 to +418
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All newly added body-length-limit tests set footer to an empty string, but the PR description states the option should affect the footer as well. Add at least one test case with a non-empty footer to ensure footer lines are wrapped/preserved correctly.

Copilot uses AI. Check for mistakes.
)

config.settings["body_length_limit"] = body_length_limit
commands.Commit(config, {})()

success_mock.assert_called_once()
committed_message = commit_mock.call_args[0][0]

# File regression check - uses test_id to create separate files
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this comment

file_regression.check(
committed_message,
extension=".txt",
basename=f"test_commit_command_body_length_limit_{test_id}",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can I ensure the first line (subject) and second line (blank) are as expected? Is this no need for the test, or is there another method to test it?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies I wasn't clear enough. I meant the basename parameter is not needed here.

)

# Validate line lengths if limit is not 0
if body_length_limit > 0:
lines = committed_message.split("\n")
body_lines = lines[2:] # Skip subject and blank line
for line in body_lines:
assert len(line) <= body_length_limit, (
f"Line exceeds {body_length_limit} chars: '{line}' ({len(line)} chars)"
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
feat: add feature

This is a very long line that exceeds 72 characters and should NOT be wrapped when body_length_limit is set to 0
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
feat: add feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
feat: add feature

Line1 that is very long and exceeds the limit
Line2 that is very long and exceeds the limit
Line3 that is very long and exceeds the limit
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
feat: add feature

This is a very long line that exceeds 72 characters and should be
automatically wrapped by the system to fit within the limit
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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).
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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).
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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).
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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).
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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).
2 changes: 2 additions & 0 deletions tests/test_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"extras": {},
"breaking_change_exclamation_in_title": False,
"message_length_limit": 0,
"body_length_limit": 0,
}

_new_settings: dict[str, Any] = {
Expand Down Expand Up @@ -152,6 +153,7 @@
"extras": {},
"breaking_change_exclamation_in_title": False,
"message_length_limit": 0,
"body_length_limit": 0,
}


Expand Down