diff --git a/commitizen/cli.py b/commitizen/cli.py index cf3d6c5ee..8b2d008e0 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 7591a2865..1f21b4557 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -61,7 +61,9 @@ def prompt_commit_questions(self) -> str: if not answers: raise NoAnswersError() - return cz.message(answers) + + message_length_limit: int = self.arguments.get("message_length_limit", 0) + return cz.message(answers, message_length_limit=message_length_limit) def __call__(self): dry_run: bool = self.arguments.get("dry_run") diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index bd116ceb0..9cbfe795d 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -9,6 +9,7 @@ from commitizen import git from commitizen.config.base_config import BaseConfig from commitizen.defaults import Questions +from commitizen.exceptions import CommitMessageLengthExceededError class MessageBuilderHook(Protocol): @@ -71,7 +72,7 @@ def questions(self) -> Questions: """Questions regarding the commit message.""" @abstractmethod - def message(self, answers: dict) -> str: + def message(self, answers: dict, message_length_limit: int) -> str: """Format your git message.""" @property @@ -105,3 +106,12 @@ def process_commit(self, commit: str) -> str: If not overwritten, it returns the first line of commit. """ return commit.split("\n")[0] + + def _check_message_length_limit( + self, message: str, message_length_limit: int + ) -> None: + message_len = len(message) + if message_length_limit > 0 and message_len > message_length_limit: + raise CommitMessageLengthExceededError( + f"Length of commit message exceeds limit ({message_len}/{message_length_limit})" + ) diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py index 5f693963e..50d6e9413 100644 --- a/commitizen/cz/conventional_commits/conventional_commits.py +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -150,7 +150,7 @@ def questions(self) -> Questions: ] return questions - def message(self, answers: dict) -> str: + def message(self, answers: dict, message_length_limit: int = 0) -> str: prefix = answers["prefix"] scope = answers["scope"] subject = answers["subject"] @@ -167,9 +167,9 @@ def message(self, answers: dict) -> str: if footer: footer = f"\n\n{footer}" - message = f"{prefix}{scope}: {subject}{body}{footer}" - - return message + message = f"{prefix}{scope}: {subject}" + self._check_message_length_limit(message, message_length_limit) + return f"{message}{body}{footer}" def example(self) -> str: return ( diff --git a/commitizen/cz/customize/customize.py b/commitizen/cz/customize/customize.py index 5c3b4e76b..0dc5b2679 100644 --- a/commitizen/cz/customize/customize.py +++ b/commitizen/cz/customize/customize.py @@ -61,12 +61,15 @@ def __init__(self, config: BaseConfig): def questions(self) -> Questions: return self.custom_settings.get("questions", [{}]) - def message(self, answers: dict) -> str: + def message(self, answers: dict, message_length_limit: int = 0) -> str: message_template = Template(self.custom_settings.get("message_template", "")) - if getattr(Template, "substitute", None): - return message_template.substitute(**answers) # type: ignore - else: - return message_template.render(**answers) + message: str = ( + message_template.substitute(**answers) # type: ignore + if getattr(Template, "substitute", None) + else message_template.render(**answers) + ) + self._check_message_length_limit(message, message_length_limit) + return message def example(self) -> str | None: return self.custom_settings.get("example") diff --git a/commitizen/cz/jira/jira.py b/commitizen/cz/jira/jira.py index a4fdcfa09..6a727f78e 100644 --- a/commitizen/cz/jira/jira.py +++ b/commitizen/cz/jira/jira.py @@ -44,8 +44,8 @@ def questions(self) -> Questions: ] return questions - def message(self, answers) -> str: - return " ".join( + def message(self, answers: dict, message_length_limit: int = 0) -> str: + message = " ".join( filter( bool, [ @@ -57,6 +57,8 @@ def message(self, answers) -> str: ], ) ) + self._check_message_length_limit(message, message_length_limit) + return message def example(self) -> str: return ( diff --git a/commitizen/defaults.py b/commitizen/defaults.py index a1651ebe8..cb3531916 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -57,6 +57,7 @@ class Settings(TypedDict, total=False): always_signoff: bool template: str | None extras: dict[str, Any] + message_length_limit: int name: str = "cz_conventional_commits" @@ -102,6 +103,7 @@ class Settings(TypedDict, total=False): "always_signoff": False, "template": None, # default provided by plugin "extras": {}, + "message_length_limit": 0, } MAJOR = "MAJOR" diff --git a/commitizen/exceptions.py b/commitizen/exceptions.py index 9cb151768..bcdd0d1e3 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,7 @@ 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 diff --git a/docs/commit.md b/docs/commit.md index 54c792f74..fe6fb415c 100644 --- a/docs/commit.md +++ b/docs/commit.md @@ -36,3 +36,13 @@ 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 for `ConventionalCommitsCz`, the limit applies only from the prefix to the subject. +In other words, everything after the first line (the body and the footer) are not counted in the length. diff --git a/docs/customization.md b/docs/customization.md index 1fd1826e0..e7832e319 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -227,9 +227,11 @@ class JiraCz(BaseCommitizen): ] return questions - def message(self, answers: dict) -> str: + def message(self, answers: dict, message_length_limit: int = 0) -> str: """Generate the message with the given answers.""" - return "{0} (#{1})".format(answers["title"], answers["issue"]) + message = "{0} (#{1})".format(answers["title"], answers["issue"]) + self._check_message_length_limit(message, message_length_limit) + return message def example(self) -> str: """Provide an example to help understand the style (OPTIONAL) diff --git a/tests/conftest.py b/tests/conftest.py index 76d2e53fb..0431a3955 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -204,10 +204,12 @@ def questions(self) -> list: }, ] - def message(self, answers: dict) -> str: + def message(self, answers: dict, message_length_limit: int = 0) -> str: prefix = answers["prefix"] subject = answers.get("subject", "default message").trim() - return f"{prefix}: {subject}" + message = f"{prefix}: {subject}" + self._check_message_length_limit(message, message_length_limit) + return message @pytest.fixture() @@ -220,7 +222,7 @@ class MockPlugin(BaseCommitizen): def questions(self) -> defaults.Questions: return [] - def message(self, answers: dict) -> str: + def message(self, answers: dict, message_length_limit: int = 0) -> str: return "" diff --git a/tests/test_cz_base.py b/tests/test_cz_base.py index 891ee0116..3b903d80b 100644 --- a/tests/test_cz_base.py +++ b/tests/test_cz_base.py @@ -1,14 +1,17 @@ import pytest from commitizen.cz.base import BaseCommitizen +from commitizen.exceptions import CommitMessageLengthExceededError class DummyCz(BaseCommitizen): def questions(self): return [{"type": "input", "name": "commit", "message": "Initial commit:\n"}] - def message(self, answers): - return answers["commit"] + def message(self, answers: dict, message_length_limit: int = 0): + message = answers["commit"] + self._check_message_length_limit(message, message_length_limit) + return message def test_base_raises_error(config): @@ -48,3 +51,16 @@ def test_process_commit(config): cz = DummyCz(config) message = cz.process_commit("test(test_scope): this is test msg") assert message == "test(test_scope): this is test msg" + + +def test_message_length_limit(config): + cz = DummyCz(config) + commit_message = "123456789" + message_length = len(commit_message) + assert cz.message({"commit": commit_message}) == commit_message + assert ( + cz.message({"commit": commit_message}, message_length_limit=message_length) + == commit_message + ) + with pytest.raises(CommitMessageLengthExceededError): + cz.message({"commit": commit_message}, message_length_limit=message_length - 1)