diff --git a/commitizen/cli.py b/commitizen/cli.py index cf3d6c5eef..8b2d008e0e 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -154,6 +154,12 @@ def __call__( "action": "store_true", "help": "Tell the command to automatically stage files that have been modified and deleted, but new files you have not told Git about are not affected.", }, + { + "name": ["-l", "--message-length-limit"], + "type": int, + "default": 0, + "help": "length limit of the commit message; 0 for no limit", + }, ], }, { diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 7591a28658..df28b2a5dd 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -12,6 +12,7 @@ from commitizen.cz.utils import get_backup_file_path from commitizen.exceptions import ( CommitError, + CommitMessageLengthExceededError, CustomError, DryRunExit, NoAnswersError, @@ -61,7 +62,16 @@ def prompt_commit_questions(self) -> str: if not answers: raise NoAnswersError() - return cz.message(answers) + + message = cz.message(answers) + message_len = len(message.partition("\n")[0].strip()) + message_length_limit: int = self.arguments.get("message_length_limit", 0) + if 0 < message_length_limit < message_len: + raise CommitMessageLengthExceededError( + f"Length of commit message exceeds limit ({message_len}/{message_length_limit})" + ) + + return message def __call__(self): dry_run: bool = self.arguments.get("dry_run") diff --git a/commitizen/cz/jira/jira.py b/commitizen/cz/jira/jira.py index a4fdcfa09e..b8fd056a71 100644 --- a/commitizen/cz/jira/jira.py +++ b/commitizen/cz/jira/jira.py @@ -44,7 +44,7 @@ def questions(self) -> Questions: ] return questions - def message(self, answers) -> str: + def message(self, answers: dict) -> str: return " ".join( filter( bool, diff --git a/commitizen/exceptions.py b/commitizen/exceptions.py index 9cb1517680..b0fc4e382d 100644 --- a/commitizen/exceptions.py +++ b/commitizen/exceptions.py @@ -36,6 +36,7 @@ class ExitCode(enum.IntEnum): CHANGELOG_FORMAT_UNKNOWN = 29 CONFIG_FILE_NOT_FOUND = 30 CONFIG_FILE_IS_EMPTY = 31 + COMMIT_MESSAGE_LENGTH_LIMIT_EXCEEDED = 32 class CommitizenException(Exception): @@ -201,3 +202,8 @@ class ConfigFileNotFound(CommitizenException): class ConfigFileIsEmpty(CommitizenException): exit_code = ExitCode.CONFIG_FILE_IS_EMPTY message = "Config file is empty, please check your file path again." + + +class CommitMessageLengthExceededError(CommitizenException): + exit_code = ExitCode.COMMIT_MESSAGE_LENGTH_LIMIT_EXCEEDED + message = "Length of commit message exceeds the given limit." diff --git a/docs/commit.md b/docs/commit.md index 54c792f743..2c1b94f63e 100644 --- a/docs/commit.md +++ b/docs/commit.md @@ -36,3 +36,14 @@ You can use `cz commit --retry` to reuse the last commit message when the previo To automatically retry when running `cz commit`, you can set the `retry_after_failure` configuration option to `true`. Running `cz commit --no-retry` makes commitizen ignore `retry_after_failure`, forcing a new commit message to be prompted. + +### Commit message length limit + +The argument `-l` (or `--message-length-limit`) followed by a positive number can limit the length of commit messages. +An exception would be raised when the message length exceeds the limit. +For example, `cz commit -l 72` will limit the length of commit messages to 72 characters. +By default the limit is set to 0, which means no limit on the length. + +**Note that the limit applies only to the first line of the message.** +Specifically, for `ConventionalCommitsCz` the length only counts from the type of change to the subject, +while the body and the footer are not counted. diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 1930b2eaee..0e0a12751a 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -9,6 +9,7 @@ from commitizen.cz.utils import get_backup_file_path from commitizen.exceptions import ( CommitError, + CommitMessageLengthExceededError, CustomError, DryRunExit, NoAnswersError, @@ -379,3 +380,29 @@ def test_commit_command_with_extra_args(config, mocker: MockFixture): commands.Commit(config, {"extra_cli_args": "-- -extra-args1 -extra-arg2"})() commit_mock.assert_called_once_with(ANY, args="-- -extra-args1 -extra-arg2") success_mock.assert_called_once() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_message_length_limit(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prefix = "feat" + subject = "random subject" + message_length = len(prefix) + len(": ") + len(subject) + prompt_mock.return_value = { + "prefix": prefix, + "subject": subject, + "scope": "", + "is_breaking_change": False, + "body": "random body", + "footer": "random footer", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + commands.Commit(config, {"message_length_limit": message_length})() + success_mock.assert_called_once() + + with pytest.raises(CommitMessageLengthExceededError): + commands.Commit(config, {"message_length_limit": message_length - 1})() diff --git a/tests/test_cz_base.py b/tests/test_cz_base.py index 891ee01167..4ee1cc6eda 100644 --- a/tests/test_cz_base.py +++ b/tests/test_cz_base.py @@ -7,7 +7,7 @@ class DummyCz(BaseCommitizen): def questions(self): return [{"type": "input", "name": "commit", "message": "Initial commit:\n"}] - def message(self, answers): + def message(self, answers: dict): return answers["commit"]