Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.",
},
{
"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)
)
# 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
Comment on lines +113 to +115
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.

return message

lines = message.split("\n")
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

wrapped_body = "\n".join(wrapped_body_lines)
return f"{subject}\n{blank_line}\n{wrapped_body}"
Comment on lines +122 to +131
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": "",
},
)

commit_mock = mocker.patch(
"commitizen.git.commit", return_value=cmd.Command("success", "", b"", b"", 0)
)
Comment on lines +387 to +389
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.

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
Loading