Skip to content
5 changes: 5 additions & 0 deletions commitizen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
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
28 changes: 28 additions & 0 deletions commitizen/commands/commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import shutil
import subprocess
import tempfile
import textwrap
from typing import TYPE_CHECKING, TypedDict

import questionary
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -102,6 +105,31 @@ 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:
Copy link
Collaborator

Choose a reason for hiding this comment

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

why _rewrap? I think _wrap_body is better

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 (
body_length_limit is None or body_length_limit <= 0
): # do nothing for no limit
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
if (
body_length_limit is None or body_length_limit <= 0
): # do nothing for no limit
if (
body_length_limit <= 0
): # do nothing for no limit

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Quick question: I understand that the value is guaranteed, but according to defensive programming, isn’t it better to handle the null value?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I am not sure whether body_length_limit is guaranteed to be an int. If it is None under some code paths, then something might be wrong.

Copy link
Member

Choose a reason for hiding this comment

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

I probably would try not body_length_limit or body_length_limit < 0

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, I found that if I don’t handle None, it can’t commit due to a Null error.

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()
body_lines = body.split("\n")
wrapped_body_lines = []
for line in body_lines:
wrapped_body_lines.append(textwrap.fill(line, width=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.

we should use wrap instead of fill

Reference: https://docs.python.org/3/library/textwrap.html#textwrap.wrap

Copy link
Member

Choose a reason for hiding this comment

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

also we should prefer using list comphrehension

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Why is wrap used instead of fill in this context?

Copy link
Collaborator

Choose a reason for hiding this comment

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

because we want to wrap instead of fill?

wrapped_body = "\n".join(wrapped_body_lines)
return f"{subject}\n{blank_line}\n{wrapped_body}"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
# 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()
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}"
# First line is subject, second is blank line, rest is body
wrapped_body_lines = [textwrap.fill(line, width=body_length_limit) for line in lines[2:]]
return "\n".join(chain(lines[:2], wrapped_body_lines))

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wow, this code is so clean! I Learned a lot.


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
227 changes: 227 additions & 0 deletions tests/commands/test_commit_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think all the new tests can be merged into one and use parameterize to shorten the tests

Basically we can do like following:

fixtures
parameterize
def test_...(...):
    mocker.patch(
        "questionary.prompt",
        return_value={
            "prefix": "feat",
            "subject": "add feature",
            "scope": "",
            "is_breaking_change": False,
            "body": body, # parameterized, can be a long text with / without line breaks or short strings or an empty string
            "footer": "",
        },
    )

    # commit with parameterized config

    # check each line in line[2:] does not exceed the line length limit if it is not 0

    # file regression check

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 understand! This will make the test more organized.

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": "",
},
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.
)

commit_mock = mocker.patch(
"commitizen.git.commit", return_value=cmd.Command("success", "", b"", b"", 0)
)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Please reuse commit_mock fixture. Same for other new tests.


# Execute with body_length_limit
commands.Commit(config, {"body_length_limit": 72})()
Copy link
Collaborator

Choose a reason for hiding this comment

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

The number 72 should be extracted.

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():
Copy link
Collaborator

Choose a reason for hiding this comment

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

This if can be removed?

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 believe so. This line was intended to skip empty lines, which are no longer necessary.

assert len(line) <= 72, (
f"Line exceeds 72 chars: '{line}' ({len(line)} chars)"
)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Add a file regression check here and remove assert line[0] == ... and assert line[1] == ...


@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, (
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why not <=?

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 test case, every line is exactly 45 characters long, so I check if it truly wraps to 45-character lines. However, there’s a case where it wraps to a line shorter than 45 characters which is not the intended behavior but test still can pass.

  • I forgot to modify the comment.

Copy link
Collaborator

Choose a reason for hiding this comment

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

That is strange. Wrapping should not guarantee exactly N chars long.

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.

This test asserts every wrapped line has length exactly 45, but textwrap wrapping does not guarantee all lines are exactly the width (the last line of a wrapped paragraph is typically shorter). This assertion will be flaky/incorrect; it should validate <= 45 (and, if needed, separately assert that wrapping occurred).

Suggested change
assert len(line) == 45, (
assert len(line) <= 45, (

Copilot uses AI. Check for mistakes.
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"
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).
Loading