diff --git a/commitizen/cli.py b/commitizen/cli.py index 79988fb5c..f78dd0c9c 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -164,6 +164,11 @@ def __call__( "type": int, "help": "Set the length limit of the commit message; 0 for no limit.", }, + { + "name": ["--preview"], + "action": "store_true", + "help": "Show live first-line (subject) preview while typing.", + }, { "name": ["--"], "action": "store_true", diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py index ab5e671d6..01faf0eb1 100644 --- a/commitizen/commands/check.py +++ b/commitizen/commands/check.py @@ -21,7 +21,7 @@ class CheckArgs(TypedDict, total=False): commit_msg: str rev_range: str allow_abort: bool - message_length_limit: int + message_length_limit: int | None allowed_prefixes: list[str] message: str use_default_range: bool @@ -46,8 +46,12 @@ def __init__(self, config: BaseConfig, arguments: CheckArgs, *args: object) -> N ) self.use_default_range = bool(arguments.get("use_default_range")) - self.max_msg_length = arguments.get( - "message_length_limit", config.settings.get("message_length_limit", 0) + + message_length_limit = arguments.get("message_length_limit") + self.message_length_limit: int = ( + message_length_limit + if message_length_limit is not None + else config.settings["message_length_limit"] ) # we need to distinguish between None and [], which is a valid value @@ -100,7 +104,7 @@ def __call__(self) -> None: pattern=pattern, allow_abort=self.allow_abort, allowed_prefixes=self.allowed_prefixes, - max_msg_length=self.max_msg_length, + max_msg_length=self.message_length_limit, commit_hash=commit.rev, ) ).is_valid diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 6668c0d65..7c2db4b12 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -25,6 +25,7 @@ NothingToCommitError, ) from commitizen.git import smart_open +from commitizen.preview_questions import build_preview_questions if TYPE_CHECKING: from commitizen.config import BaseConfig @@ -35,11 +36,12 @@ class CommitArgs(TypedDict, total=False): dry_run: bool edit: bool extra_cli_args: str - message_length_limit: int + message_length_limit: int | None no_retry: bool signoff: bool write_message_to_file: Path | None retry: bool + preview: bool class Commit: @@ -54,6 +56,18 @@ def __init__(self, config: BaseConfig, arguments: CommitArgs) -> None: self.arguments = arguments self.backup_file_path = get_backup_file_path() + message_length_limit = arguments.get("message_length_limit") + self.message_length_limit: int = ( + message_length_limit + if message_length_limit is not None + else config.settings["message_length_limit"] + ) + + self.preview_enabled = bool( + self.arguments.get("preview", False) + or self.config.settings.get("preview", False) + ) + def _read_backup_message(self) -> str | None: # Check the commit backup file exists if not self.backup_file_path.is_file(): @@ -65,12 +79,19 @@ def _read_backup_message(self) -> str | None: ).strip() def _get_message_by_prompt_commit_questions(self) -> str: - # Prompt user for the commit message questions = self.cz.questions() for question in (q for q in questions if q["type"] == "list"): question["use_shortcuts"] = self.config.settings["use_shortcuts"] + + questions_to_ask = build_preview_questions( + self.cz, + questions, + enabled=self.preview_enabled, + max_length=self.message_length_limit, + ) + try: - answers = questionary.prompt(questions, style=self.cz.style) + answers = questionary.prompt(questions_to_ask, style=self.cz.style) except ValueError as err: root_err = err.__context__ if isinstance(root_err, CzException): @@ -85,19 +106,14 @@ def _get_message_by_prompt_commit_questions(self) -> str: return message def _validate_subject_length(self, message: str) -> None: - message_length_limit = self.arguments.get( - "message_length_limit", self.config.settings.get("message_length_limit", 0) - ) # By the contract, message_length_limit is set to 0 for no limit - if ( - message_length_limit is None or message_length_limit <= 0 - ): # do nothing for no limit + if self.message_length_limit <= 0: return subject = message.partition("\n")[0].strip() - if len(subject) > message_length_limit: + if len(subject) > self.message_length_limit: raise CommitMessageLengthExceededError( - f"Length of commit message exceeds limit ({len(subject)}/{message_length_limit}), subject: '{subject}'" + f"Length of commit message exceeds limit ({len(subject)}/{self.message_length_limit}), subject: '{subject}'" ) def manual_edit(self, message: str) -> str: diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 4865ccc18..b9a500002 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -65,6 +65,7 @@ class Settings(TypedDict, total=False): version_type: str | None version: str | None breaking_change_exclamation_in_title: bool + preview: bool CONFIG_FILES: tuple[str, ...] = ( @@ -115,6 +116,7 @@ class Settings(TypedDict, total=False): "extras": {}, "breaking_change_exclamation_in_title": False, "message_length_limit": 0, # 0 for no limit + "preview": False, } MAJOR = "MAJOR" diff --git a/commitizen/interactive_preview.py b/commitizen/interactive_preview.py new file mode 100644 index 000000000..9963c15dd --- /dev/null +++ b/commitizen/interactive_preview.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from collections.abc import Callable +from shutil import get_terminal_size + +from prompt_toolkit.utils import get_cwidth + +SubjectBuilder = Callable[[str, str], str] + + +def _wrap_display_width(text: str, width: int) -> list[str]: + """Wrap text by terminal display width (columns). + + Each character width is computed with get_cwidth so CJK/full-width + characters are handled correctly. + """ + if width <= 0 or not text: + return [text] if text else [] + + lines: list[str] = [] + current: list[str] = [] + current_width = 0 + + for char in text: + char_width = get_cwidth(char) + if current_width + char_width > width and current: + lines.append("".join(current)) + current = [] + current_width = 0 + + current.append(char) + current_width += char_width + + if current: + lines.append("".join(current)) + + return lines + + +def make_toolbar_content( + subject_builder: SubjectBuilder, + current_field: str, + current_text: str, + *, + max_length: int, +) -> str: + """Build bottom toolbar content with live preview and length counter. + + - First line (or multiple lines): preview of the commit subject, wrapped by + terminal display width. + - Last line: character count, optionally including the max length. + """ + preview = subject_builder(current_field, current_text) + current_length = len(preview) + + counter = ( + f"{current_length}/{max_length} chars" + if max_length > 0 + else f"{current_length} chars" + ) + + try: + width = get_terminal_size().columns + except OSError: + width = 80 + + wrapped_preview = _wrap_display_width(preview, width) + preview_block = "\n".join(wrapped_preview) + + padding = max(0, width - len(counter)) + counter_line = f"{' ' * padding}{counter}" + + return f"{preview_block}\n{counter_line}" + + +def make_length_validator( + subject_builder: SubjectBuilder, + field: str, + *, + max_length: int, +) -> Callable[[str], bool | str]: + """Create a questionary-style validator for subject length. + + The validator: + - Uses the subject_builder to get the full preview string for the current + answers_state and field value. + - Applies max_length on the character count (len). A value of 0 disables + the limit. + """ + + def _validate(text: str) -> bool | str: + if max_length <= 0: + return True + + preview = subject_builder(field, text) + length = len(preview) + if length <= max_length: + return True + + return f"{length}/{max_length} chars (subject length exceeded)" + + return _validate diff --git a/commitizen/preview_questions.py b/commitizen/preview_questions.py new file mode 100644 index 000000000..9b5b0dec9 --- /dev/null +++ b/commitizen/preview_questions.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from prompt_toolkit.application.current import get_app + +from commitizen.interactive_preview import ( + make_length_validator as make_length_validator_preview, +) +from commitizen.interactive_preview import ( + make_toolbar_content as make_toolbar_content_preview, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from commitizen.cz.base import BaseCommitizen + from commitizen.question import CzQuestion + + +# Questionary types for interactive preview hooks (length validator / toolbar), +# based on questionary 2.0.1 +VALIDATABLE_TYPES = {"input", "text", "password", "path", "checkbox"} +BOTTOM_TOOLBAR_TYPES = {"input", "text", "password", "confirm"} + + +def build_preview_questions( + cz: BaseCommitizen, + questions: list[CzQuestion], + *, + enabled: bool, + max_length: int, +) -> list[CzQuestion]: + """Return questions enhanced with interactive preview, when enabled.""" + if not enabled: + return questions + + max_preview_length = max_length + + default_answers: dict[str, Any] = { + q["name"]: q.get("default", "") + for q in questions + if isinstance(q.get("name"), str) + } + field_filters: dict[str, Callable[[str], str] | None] = { + q["name"]: q.get("filter") for q in questions if isinstance(q.get("name"), str) + } + answers_state: dict[str, Any] = {} + + def _get_current_buffer_text() -> str: + try: + app = get_app() + buffer = app.layout.current_buffer + return buffer.text if buffer is not None else "" + except Exception: + return "" + + def subject_builder(current_field: str, current_text: str) -> str: + preview_answers: dict[str, Any] = default_answers.copy() + preview_answers.update(answers_state) + if current_field: + field_filter = field_filters.get(current_field) + if field_filter: + try: + preview_answers[current_field] = field_filter(current_text) + except Exception: + preview_answers[current_field] = current_text + else: + preview_answers[current_field] = current_text + try: + return cz.message(preview_answers).partition("\n")[0].strip() + except Exception: + return "" + + def make_stateful_filter( + name: str, original_filter: Callable[[str], str] | None + ) -> Callable[[str], str]: + def _filter(raw: str) -> str: + value = original_filter(raw) if original_filter else raw + answers_state[name] = value + return value + + return _filter + + def make_toolbar(name: str) -> Callable[[], str]: + def _toolbar() -> str: + return make_toolbar_content_preview( + subject_builder, + name, + _get_current_buffer_text(), + max_length=max_preview_length, + ) + + return _toolbar + + def make_length_validator(name: str) -> Callable[[str], bool | str]: + return make_length_validator_preview( + subject_builder, + name, + max_length=max_preview_length, + ) + + enhanced_questions: list[CzQuestion] = [] + for q in questions: + q_dict = q.copy() + q_type = q_dict.get("type") + name = q_dict.get("name") + + if isinstance(name, str): + original_filter = q_dict.get("filter") + q_dict["filter"] = make_stateful_filter(name, original_filter) + + if q_type in BOTTOM_TOOLBAR_TYPES: + q_dict["bottom_toolbar"] = make_toolbar(name) + + if q_type in VALIDATABLE_TYPES: + q_dict["validate"] = make_length_validator(name) + + enhanced_questions.append(q_dict) + + return enhanced_questions diff --git a/commitizen/question.py b/commitizen/question.py index 043b8f3ba..e01328d43 100644 --- a/commitizen/question.py +++ b/commitizen/question.py @@ -14,6 +14,9 @@ class ListQuestion(TypedDict, total=False): message: str choices: list[Choice] use_shortcuts: bool + filter: Callable[[str], str] + bottom_toolbar: Callable[[], str] + validate: Callable[[str], bool | str] class InputQuestion(TypedDict, total=False): @@ -21,13 +24,18 @@ class InputQuestion(TypedDict, total=False): name: str message: str filter: Callable[[str], str] + bottom_toolbar: Callable[[], str] + validate: Callable[[str], bool | str] -class ConfirmQuestion(TypedDict): +class ConfirmQuestion(TypedDict, total=False): type: Literal["confirm"] name: str message: str default: bool + filter: Callable[[str], str] + bottom_toolbar: Callable[[], str] + validate: Callable[[str], bool | str] CzQuestion = ListQuestion | InputQuestion | ConfirmQuestion diff --git a/docs/commands/commit.md b/docs/commands/commit.md index 5e93a2274..f55756148 100644 --- a/docs/commands/commit.md +++ b/docs/commands/commit.md @@ -60,6 +60,26 @@ cz commit -l 72 # Limits message length to 72 characters !!! note The length limit only applies to the first line of the commit message. For conventional commits, this means the limit applies from the type of change through the subject. The body and footer are not counted. +### Live Subject Preview + +Enable a live preview of your commit *subject* while you are typing with `cz commit --preview`: + +```sh +cz commit --preview +``` + +You can also turn it on via configuration by setting: + +```toml +preview = true +``` + +![Live subject preview](../images/cli_interactive/commit_preview.gif) + +When `--preview` is enabled, the bottom toolbar always shows a live character counter while you type. With `-l/--message-length-limit`, the counter switches to `current/max chars`, and input is validated against the same first-line subject length rules. + +![Live subject preview with length limit](../images/cli_interactive/commit_preview_length_limit.gif) + ## Technical Notes For platform compatibility, the `commit` command disables ANSI escaping in its output. This means pre-commit hooks coloring will be deactivated as discussed in [commitizen-tools/commitizen#417](https://github.com/commitizen-tools/commitizen/issues/417). diff --git a/docs/config/configuration_file.md b/docs/config/configuration_file.md index 172cbce1a..e111927de 100644 --- a/docs/config/configuration_file.md +++ b/docs/config/configuration_file.md @@ -76,6 +76,7 @@ All formats support the same configuration options. Choose the format that best retry_after_failure = false allow_abort = false message_length_limit = 0 + preview = false allowed_prefixes = [ "Merge", "Revert", @@ -134,6 +135,7 @@ All formats support the same configuration options. Choose the format that best "retry_after_failure": false, "allow_abort": false, "message_length_limit": 0, + "preview": false, "allowed_prefixes": [ "Merge", "Revert", @@ -190,6 +192,7 @@ All formats support the same configuration options. Choose the format that best retry_after_failure: false allow_abort: false message_length_limit: 0 + preview: false allowed_prefixes: - Merge - Revert @@ -237,6 +240,7 @@ Key configuration categories include: - **Changelog**: `changelog_file`, `changelog_format`, `changelog_incremental`, `update_changelog_on_bump` - **Bumping**: `bump_message`, `major_version_zero`, `prerelease_offset`, `pre_bump_hooks`, `post_bump_hooks` - **Commit Validation**: `allowed_prefixes`, `message_length_limit`, `allow_abort`, `retry_after_failure` +- **Interactive Preview**: `preview` - **Customization**: `customize`, `style`, `use_shortcuts`, `template`, `extras` ## Customization diff --git a/docs/images/cli_help/cz_commit___help.svg b/docs/images/cli_help/cz_commit___help.svg index 633cea8fd..2c2993214 100644 --- a/docs/images/cli_help/cz_commit___help.svg +++ b/docs/images/cli_help/cz_commit___help.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + - + - + - - $ cz commit --help -usage: cz commit [-h][--retry][--no-retry][--dry-run] -[--write-message-to-file FILE_PATH][-s][-a][-e] -[-l MESSAGE_LENGTH_LIMIT][--] - -Create new commit - -options: -  -h, --help            show this help message and exit -  --retry               Retry the last commit. -  --no-retry            Skip retry if --retry or `retry_after_failure` is set -                        to true. -  --dry-run             Perform a dry run, without committing or modifying -                        files. -  --write-message-to-file FILE_PATH -                        Write message to FILE_PATH before committing (can be -                        used with --dry-run). -  -s, --signoff         Deprecated, use `cz commit -- -s` instead. -  -a, --all             Automatically stage files that have been modified and -                        deleted, but new files you have not told Git about are -                        not affected. -  -e, --edit            Edit the commit message before committing. -  -l MESSAGE_LENGTH_LIMIT, --message-length-limit MESSAGE_LENGTH_LIMIT -                        Set the length limit of the commit message; 0 for no -                        limit. -  --                    Positional arguments separator (recommended). - + + $ cz commit --help +usage: cz commit [-h][--retry][--no-retry][--dry-run] +[--write-message-to-file FILE_PATH][-s][-a][-e] +[-l MESSAGE_LENGTH_LIMIT][--preview][--] + +Create new commit + +options: +  -h, --help            show this help message and exit +  --retry               Retry the last commit. +  --no-retry            Skip retry if --retry or `retry_after_failure` is set +                        to true. +  --dry-run             Perform a dry run, without committing or modifying +                        files. +  --write-message-to-file FILE_PATH +                        Write message to FILE_PATH before committing (can be +                        used with --dry-run). +  -s, --signoff         Deprecated, use `cz commit -- -s` instead. +  -a, --all             Automatically stage files that have been modified and +                        deleted, but new files you have not told Git about are +                        not affected. +  -e, --edit            Edit the commit message before committing. +  -l, --message-length-limit MESSAGE_LENGTH_LIMIT +                        Set the length limit of the commit message; 0 for no +                        limit. +  --preview             Show live first-line (subject) preview while typing. +  --                    Positional arguments separator (recommended). + diff --git a/docs/images/cli_interactive/commit_preview.gif b/docs/images/cli_interactive/commit_preview.gif new file mode 100644 index 000000000..cac31b94d Binary files /dev/null and b/docs/images/cli_interactive/commit_preview.gif differ diff --git a/docs/images/cli_interactive/commit_preview_length_limit.gif b/docs/images/cli_interactive/commit_preview_length_limit.gif new file mode 100644 index 000000000..1e53d2e00 Binary files /dev/null and b/docs/images/cli_interactive/commit_preview_length_limit.gif differ diff --git a/docs/images/commit_preview.tape b/docs/images/commit_preview.tape new file mode 100644 index 000000000..118b98d05 --- /dev/null +++ b/docs/images/commit_preview.tape @@ -0,0 +1,122 @@ +Output cli_interactive/commit_preview.gif + +Require cz + +# Use bash for cross-platform compatibility (macOS, Linux, Windows) +Set Shell bash + +Set FontSize 16 +Set Width 878 +Set Height 568 +Set Padding 20 +Set TypingSpeed 50ms + +Set Theme { + "name": "Commitizen", + "black": "#232628", + "red": "#fc4384", + "green": "#b3e33b", + "yellow": "#ffa727", + "blue": "#75dff2", + "magenta": "#ae89fe", + "cyan": "#708387", + "white": "#d5d5d0", + "brightBlack": "#626566", + "brightRed": "#ff7fac", + "brightGreen": "#c8ed71", + "brightYellow": "#ebdf86", + "brightBlue": "#75dff2", + "brightMagenta": "#ae89fe", + "brightCyan": "#b1c6ca", + "brightWhite": "#f9f9f4", + "background": "#1e1e2e", + "foreground": "#afafaf", + "cursor": "#c7c7c7" +} + +# Hide initial shell prompt +Hide + +# Wait for terminal to be ready +Sleep 1s + +# Set a clean, simple prompt (while hidden) +Type "PS1='$ '" +Enter +Sleep 300ms + +# Create a clean temporary directory for recording +Type "rm -rf /tmp/commitizen-example && mkdir -p /tmp/commitizen-example && cd /tmp/commitizen-example" +Enter +Sleep 500ms + +# Initialize git repository +Type "git init" +Enter +Type "git config user.email 'you@example.com'" +Enter +Type "git config user.name 'Your Name'" +Enter +Sleep 500ms + +Type "git checkout -b awesome-feature" +Enter +Sleep 500ms + +# Create a dummy file to commit +Type "echo 'test content' > example.py" +Enter +Sleep 300ms + +Type "git add example.py" +Enter +Sleep 300ms + +# Clear the screen to start fresh +Type "clear" +Enter +Sleep 500ms + +# Show commands from here +Show + +# Now run cz commit +Type "cz commit --preview" +Sleep 500ms +Enter + +# Wait for first prompt to appear +Sleep 1s + +# Question 1: Select the type of change (move down to "feat") +Down +Sleep 1s +Enter +Sleep 1s + +# Question 2: Scope (optional, skip) +Type "preview commit subject" +Sleep 1s +Enter +Sleep 1s + +# Question 3: Subject +Type "awesome new feature" +Sleep 1s +Enter +Sleep 1s + +# Question 4: Is this a BREAKING CHANGE? (No) +Enter +Sleep 500ms + +# Question 5: Body (optional, skip) +Enter +Sleep 500ms + +# Question 6: Footer (optional, skip) +Enter +Sleep 500ms + +# Wait for commit success message +Sleep 2s diff --git a/docs/images/commit_preview_length_limit.tape b/docs/images/commit_preview_length_limit.tape new file mode 100644 index 000000000..6d06966d4 --- /dev/null +++ b/docs/images/commit_preview_length_limit.tape @@ -0,0 +1,124 @@ +Output cli_interactive/commit_preview_length_limit.gif + +Require cz + +# Use bash for cross-platform compatibility (macOS, Linux, Windows) +Set Shell bash + +Set FontSize 16 +Set Width 878 +Set Height 568 +Set Padding 20 +Set TypingSpeed 50ms + +Set Theme { + "name": "Commitizen", + "black": "#232628", + "red": "#fc4384", + "green": "#b3e33b", + "yellow": "#ffa727", + "blue": "#75dff2", + "magenta": "#ae89fe", + "cyan": "#708387", + "white": "#d5d5d0", + "brightBlack": "#626566", + "brightRed": "#ff7fac", + "brightGreen": "#c8ed71", + "brightYellow": "#ebdf86", + "brightBlue": "#75dff2", + "brightMagenta": "#ae89fe", + "brightCyan": "#b1c6ca", + "brightWhite": "#f9f9f4", + "background": "#1e1e2e", + "foreground": "#afafaf", + "cursor": "#c7c7c7" +} + +# Hide initial shell prompt +Hide + +# Wait for terminal to be ready +Sleep 1s + +# Set a clean, simple prompt (while hidden) +Type "PS1='$ '" +Enter +Sleep 300ms + +# Create a clean temporary directory for recording +Type "rm -rf /tmp/commitizen-example && mkdir -p /tmp/commitizen-example && cd /tmp/commitizen-example" +Enter +Sleep 500ms + +# Initialize git repository +Type "git init" +Enter +Type "git config user.email 'you@example.com'" +Enter +Type "git config user.name 'Your Name'" +Enter +Sleep 500ms + +Type "git checkout -b awesome-feature" +Enter +Sleep 500ms + +# Create a dummy file to commit +Type "echo 'test content' > example.py" +Enter +Sleep 300ms + +Type "git add example.py" +Enter +Sleep 300ms + +# Clear the screen to start fresh +Type "clear" +Enter +Sleep 500ms + +# Show commands from here +Show + +# Now run cz commit +Type "cz commit --preview -l 72" +Sleep 500ms +Enter + +# Wait for first prompt to appear +Sleep 1s + +# Question 1: Select the type of change (move down to "feat") +Down +Sleep 1s +Enter +Sleep 1s + +# Question 2: Scope (optional, skip) +Type "preview commit subject" +Sleep 1s +Enter +Sleep 1s + +# Question 3: Subject +Type "awesome new feature 11111111111111111111111111111111" +Sleep 1s +Backspace 10 +Sleep 1s +Enter +Sleep 1s + +# Question 4: Is this a BREAKING CHANGE? (No) +Enter +Sleep 500ms + +# Question 5: Body (optional, skip) +Enter +Sleep 500ms + +# Question 6: Footer (optional, skip) +Enter +Sleep 500ms + +# Wait for commit success message +Sleep 2s diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py index f3f860313..5abd0688a 100644 --- a/tests/commands/test_check_command.py +++ b/tests/commands/test_check_command.py @@ -351,23 +351,27 @@ def test_check_command_with_amend_prefix_default(config, success_mock): success_mock.assert_called_once() -def test_check_command_with_config_message_length_limit(config, success_mock): +def test_check_command_with_config_message_length_limit_and_cli_none( + config, success_mock: MockType +): message = "fix(scope): some commit message" config.settings["message_length_limit"] = len(message) + 1 commands.Check( config=config, - arguments={"message": message}, + arguments={"message": message, "message_length_limit": None}, )() success_mock.assert_called_once() -def test_check_command_with_config_message_length_limit_exceeded(config): +def test_check_command_with_config_message_length_limit_exceeded_and_cli_none( + config, +): message = "fix(scope): some commit message" config.settings["message_length_limit"] = len(message) - 1 with pytest.raises(CommitMessageLengthExceededError): commands.Check( config=config, - arguments={"message": message}, + arguments={"message": message, "message_length_limit": None}, )() @@ -376,7 +380,7 @@ def test_check_command_cli_overrides_config_message_length_limit( ): message = "fix(scope): some commit message" config.settings["message_length_limit"] = len(message) - 1 - for message_length_limit in [len(message) + 1, 0]: + for message_length_limit in [len(message), 0]: success_mock.reset_mock() commands.Check( config=config, diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 2322cb3cb..53ecfded0 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -336,34 +336,101 @@ def test_commit_when_nothing_added_to_commit(config, mocker: MockFixture, out): error_mock.assert_called_once_with(out) -@pytest.mark.usefixtures("staging_is_clean", "commit_mock") -def test_commit_command_with_config_message_length_limit( - config, success_mock: MockType, prompt_mock_feat: MockType -): +def _commit_first_line_len(prompt_mock_feat: MockType) -> int: prefix = prompt_mock_feat.return_value["prefix"] subject = prompt_mock_feat.return_value["subject"] - message_length = len(f"{prefix}: {subject}") + scope = prompt_mock_feat.return_value["scope"] + + formatted_scope = f"({scope})" if scope else "" + first_line = f"{prefix}{formatted_scope}: {subject}" + return len(first_line) + - commands.Commit(config, {"message_length_limit": message_length})() +@pytest.mark.usefixtures("staging_is_clean", "commit_mock", "prompt_mock_feat") +def test_commit_message_length_cli_at_limit_succeeds( + config, success_mock: MockType, prompt_mock_feat: MockType +): + message_len = _commit_first_line_len(prompt_mock_feat) + commands.Commit(config, {"message_length_limit": message_len})() success_mock.assert_called_once() + +@pytest.mark.usefixtures("staging_is_clean", "commit_mock", "prompt_mock_feat") +def test_commit_message_length_cli_below_limit_raises( + config, prompt_mock_feat: MockType +): + message_len = _commit_first_line_len(prompt_mock_feat) with pytest.raises(CommitMessageLengthExceededError): - commands.Commit(config, {"message_length_limit": message_length - 1})() + commands.Commit(config, {"message_length_limit": message_len - 1})() - config.settings["message_length_limit"] = message_length - success_mock.reset_mock() - commands.Commit(config, {})() + +@pytest.mark.usefixtures("staging_is_clean", "commit_mock", "prompt_mock_feat") +def test_commit_message_length_uses_config_when_cli_unset( + config, success_mock: MockType, prompt_mock_feat: MockType +): + config.settings["message_length_limit"] = _commit_first_line_len(prompt_mock_feat) + commands.Commit(config, {"message_length_limit": None})() success_mock.assert_called_once() - config.settings["message_length_limit"] = message_length - 1 + +@pytest.mark.usefixtures("staging_is_clean", "commit_mock", "prompt_mock_feat") +def test_commit_message_length_config_exceeded_when_cli_unset( + config, prompt_mock_feat: MockType +): + config.settings["message_length_limit"] = ( + _commit_first_line_len(prompt_mock_feat) - 1 + ) with pytest.raises(CommitMessageLengthExceededError): - commands.Commit(config, {})() + commands.Commit(config, {"message_length_limit": None})() + - # Test config message length limit is overridden by CLI argument - success_mock.reset_mock() - commands.Commit(config, {"message_length_limit": message_length})() +@pytest.mark.usefixtures("staging_is_clean", "commit_mock", "prompt_mock_feat") +def test_commit_message_length_cli_overrides_stricter_config( + config, success_mock: MockType, prompt_mock_feat: MockType +): + message_len = _commit_first_line_len(prompt_mock_feat) + config.settings["message_length_limit"] = message_len - 1 + commands.Commit(config, {"message_length_limit": message_len})() success_mock.assert_called_once() - success_mock.reset_mock() + +@pytest.mark.usefixtures("staging_is_clean", "commit_mock", "prompt_mock_feat") +def test_commit_message_length_cli_zero_disables_limit( + config, success_mock: MockType, prompt_mock_feat: MockType +): + config.settings["message_length_limit"] = ( + _commit_first_line_len(prompt_mock_feat) - 1 + ) commands.Commit(config, {"message_length_limit": 0})() success_mock.assert_called_once() + + +@pytest.mark.usefixtures("staging_is_clean", "commit_mock") +def test_commit_preview_enhances_questions_passed_to_questionary_prompt( + config, mocker: MockFixture +): + prompt_return = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "closes #21", + "footer": "", + } + + def prompt_side_effect(questions_to_ask, style=None): + input_questions = [q for q in questions_to_ask if q.get("type") == "input"] + assert input_questions, "Expected at least one input question" + q = input_questions[0] + assert callable(q.get("bottom_toolbar")) + assert callable(q.get("validate")) + assert callable(q.get("filter")) + return prompt_return + + prompt_mock = mocker.patch("questionary.prompt", side_effect=prompt_side_effect) + + commit_cmd = commands.Commit(config, {"preview": True, "message_length_limit": 0}) + message = commit_cmd._get_message_by_prompt_commit_questions() + + prompt_mock.assert_called_once() + assert "feat:" in message diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_commit_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_commit_.txt index bd256ccf8..473bf7cca 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_commit_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_10_commit_.txt @@ -1,6 +1,6 @@ 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] [--preview] [--] Create new commit @@ -22,4 +22,5 @@ options: -l MESSAGE_LENGTH_LIMIT, --message-length-limit MESSAGE_LENGTH_LIMIT Set the length limit of the commit message; 0 for no limit. + --preview Show live first-line (subject) preview while typing. -- Positional arguments separator (recommended). diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_commit_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_commit_.txt index bd256ccf8..473bf7cca 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_commit_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_11_commit_.txt @@ -1,6 +1,6 @@ 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] [--preview] [--] Create new commit @@ -22,4 +22,5 @@ options: -l MESSAGE_LENGTH_LIMIT, --message-length-limit MESSAGE_LENGTH_LIMIT Set the length limit of the commit message; 0 for no limit. + --preview Show live first-line (subject) preview while typing. -- Positional arguments separator (recommended). diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_commit_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_commit_.txt index bd256ccf8..473bf7cca 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_commit_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_12_commit_.txt @@ -1,6 +1,6 @@ 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] [--preview] [--] Create new commit @@ -22,4 +22,5 @@ options: -l MESSAGE_LENGTH_LIMIT, --message-length-limit MESSAGE_LENGTH_LIMIT Set the length limit of the commit message; 0 for no limit. + --preview Show live first-line (subject) preview while typing. -- Positional arguments separator (recommended). diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_commit_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_commit_.txt index cbd5780f6..f3512f412 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_commit_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_13_commit_.txt @@ -1,6 +1,6 @@ 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] [--preview] [--] Create new commit @@ -22,4 +22,5 @@ options: -l, --message-length-limit MESSAGE_LENGTH_LIMIT Set the length limit of the commit message; 0 for no limit. + --preview Show live first-line (subject) preview while typing. -- Positional arguments separator (recommended). diff --git a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_commit_.txt b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_commit_.txt index cbd5780f6..f3512f412 100644 --- a/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_commit_.txt +++ b/tests/commands/test_common_command/test_command_shows_description_when_use_help_option_py_3_14_commit_.txt @@ -1,6 +1,6 @@ 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] [--preview] [--] Create new commit @@ -22,4 +22,5 @@ options: -l, --message-length-limit MESSAGE_LENGTH_LIMIT Set the length limit of the commit message; 0 for no limit. + --preview Show live first-line (subject) preview while typing. -- Positional arguments separator (recommended). diff --git a/tests/test_cli_config_integration.py b/tests/test_cli_config_integration.py new file mode 100644 index 000000000..2406549ba --- /dev/null +++ b/tests/test_cli_config_integration.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from commitizen.exceptions import CommitMessageLengthExceededError, DryRunExit + +if TYPE_CHECKING: + from pathlib import Path + + from tests.utils import UtilFixture + + +def _write_pyproject_with_message_length_limit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, message_length_limit: int +) -> None: + (tmp_path / "pyproject.toml").write_text( + "[tool.commitizen]\n" + 'name = "cz_conventional_commits"\n' + f"message_length_limit = {message_length_limit}\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + +def _mock_commit_prompt(mocker, *, subject: str) -> None: + mocker.patch( + "questionary.prompt", + return_value={ + "prefix": "feat", + "subject": subject, + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + }, + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_cli_check_reads_message_length_limit_from_pyproject( + util: UtilFixture, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + _write_pyproject_with_message_length_limit(tmp_path, monkeypatch, 10) + + long_message_file = tmp_path / "long_message.txt" + long_message_file.write_text("feat: this is definitely too long", encoding="utf-8") + + with pytest.raises(CommitMessageLengthExceededError): + util.run_cli("check", "--commit-msg-file", str(long_message_file)) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_cli_commit_reads_message_length_limit_from_pyproject( + util: UtilFixture, + monkeypatch: pytest.MonkeyPatch, + mocker, + tmp_path: Path, +): + _write_pyproject_with_message_length_limit(tmp_path, monkeypatch, 10) + _mock_commit_prompt(mocker, subject="this is definitely too long") + mocker.patch("commitizen.git.is_staging_clean", return_value=False) + + with pytest.raises(CommitMessageLengthExceededError): + util.run_cli("commit", "--dry-run") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_cli_check_cli_overrides_message_length_limit_from_pyproject( + util: UtilFixture, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + _write_pyproject_with_message_length_limit(tmp_path, monkeypatch, 10) + + util.run_cli("check", "-l", "0", "--message", "feat: this is definitely too long") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_cli_commit_cli_overrides_message_length_limit_from_pyproject( + util: UtilFixture, + monkeypatch: pytest.MonkeyPatch, + mocker, + tmp_path: Path, +): + _write_pyproject_with_message_length_limit(tmp_path, monkeypatch, 10) + _mock_commit_prompt(mocker, subject="this is definitely too long") + mocker.patch("commitizen.git.is_staging_clean", return_value=False) + + with pytest.raises(DryRunExit): + util.run_cli("commit", "--dry-run", "-l", "100") diff --git a/tests/test_conf.py b/tests/test_conf.py index 94bca4e77..3a83135b1 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -111,6 +111,7 @@ "template": None, "extras": {}, "breaking_change_exclamation_in_title": False, + "preview": False, "message_length_limit": 0, } @@ -151,6 +152,7 @@ "template": None, "extras": {}, "breaking_change_exclamation_in_title": False, + "preview": False, "message_length_limit": 0, } diff --git a/tests/test_interactive_preview.py b/tests/test_interactive_preview.py new file mode 100644 index 000000000..01d93a6be --- /dev/null +++ b/tests/test_interactive_preview.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from commitizen import interactive_preview + +if TYPE_CHECKING: + import pytest + + +def test_wrap_display_width_empty_and_non_positive_width(): + assert interactive_preview._wrap_display_width("", 10) == [] + assert interactive_preview._wrap_display_width("abc", 0) == ["abc"] + assert interactive_preview._wrap_display_width("abc", -1) == ["abc"] + + +def test_wrap_display_width_ascii_simple_wrap(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(interactive_preview, "get_cwidth", lambda _c: 1) + assert interactive_preview._wrap_display_width("abcd", 2) == ["ab", "cd"] + assert interactive_preview._wrap_display_width("abc", 2) == ["ab", "c"] + + +def test_wrap_display_width_cjk_width_2(monkeypatch: pytest.MonkeyPatch): + def fake_cwidth(c: str) -> int: + return 2 if c == "你" else 1 + + monkeypatch.setattr(interactive_preview, "get_cwidth", fake_cwidth) + assert interactive_preview._wrap_display_width("你a", 2) == ["你", "a"] + + +def test_make_toolbar_content_includes_preview_and_counter_with_max( + monkeypatch: pytest.MonkeyPatch, + mocker, +): + def subject_builder(_field: str, _text: str) -> str: + return "feat: abc" + + monkeypatch.setattr( + interactive_preview, + "get_terminal_size", + lambda: mocker.Mock(columns=20), + ) + + content = interactive_preview.make_toolbar_content( + subject_builder, "subject", "abc", max_length=50 + ) + preview, counter = content.splitlines() + assert preview == "feat: abc" + assert counter.strip() == "9/50 chars" + + +def test_make_toolbar_content_counter_without_max_when_zero( + monkeypatch: pytest.MonkeyPatch, + mocker, +): + def subject_builder(_field: str, _text: str) -> str: + return "fix: a" + + monkeypatch.setattr( + interactive_preview, + "get_terminal_size", + lambda: mocker.Mock(columns=80), + ) + content = interactive_preview.make_toolbar_content( + subject_builder, "subject", "a", max_length=0 + ) + assert content.splitlines()[-1].strip() == "6 chars" + + +def test_make_toolbar_content_terminal_size_oserror_fallback_80( + monkeypatch: pytest.MonkeyPatch, +): + def subject_builder(_field: str, _text: str) -> str: + return "feat: abc" + + def raise_oserror(): + raise OSError("no tty") + + monkeypatch.setattr(interactive_preview, "get_terminal_size", raise_oserror) + + content = interactive_preview.make_toolbar_content( + subject_builder, "subject", "abc", max_length=50 + ) + assert content.splitlines()[-1].startswith(" " * (80 - len("9/50 chars"))) + + +def test_make_toolbar_content_counter_padding_not_negative( + monkeypatch: pytest.MonkeyPatch, + mocker, +): + def subject_builder(_field: str, _text: str) -> str: + return "x" + + # Make width tiny so counter is longer than width + monkeypatch.setattr( + interactive_preview, + "get_terminal_size", + lambda: mocker.Mock(columns=3), + ) + content = interactive_preview.make_toolbar_content( + subject_builder, "subject", "x", max_length=50 + ) + assert content.splitlines()[-1] == "1/50 chars" + + +def test_make_length_validator_disabled_when_max_length_zero(): + def subject_builder(_field: str, text: str) -> str: + return text + + validate = interactive_preview.make_length_validator( + subject_builder, "subject", max_length=0 + ) + assert validate("x" * 10) is True + + +def test_make_length_validator_returns_error_string_when_exceeded(): + def subject_builder(_field: str, text: str) -> str: + return text + + validate = interactive_preview.make_length_validator( + subject_builder, "subject", max_length=3 + ) + assert validate("abc") is True + assert validate("abcd") == "4/3 chars (subject length exceeded)" diff --git a/tests/test_preview_questions.py b/tests/test_preview_questions.py new file mode 100644 index 000000000..75606d5d8 --- /dev/null +++ b/tests/test_preview_questions.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from commitizen.cz.base import BaseCommitizen +from commitizen.preview_questions import build_preview_questions + +if TYPE_CHECKING: + from collections.abc import Mapping + + import pytest + from pytest_mock import MockerFixture + + from commitizen.question import CzQuestion + + +class PreviewCz(BaseCommitizen): + def __init__(self, config) -> None: + super().__init__(config) + self.calls: list[dict[str, Any]] = [] + + def questions(self) -> list[CzQuestion]: + return [] + + def message(self, answers: Mapping[str, Any]) -> str: + self.calls.append(dict(answers)) + return f"{answers.get('prefix', '')}: {answers.get('subject', '')}".strip() + + def schema(self) -> str: + return "" + + def schema_pattern(self) -> str: + return "" + + def example(self) -> str: + return "" + + def info(self) -> str: + return "" + + +def _make_fake_prompt_app(mocker: MockerFixture, buffer_text: str): + """Object graph for get_app().layout.current_buffer.text (prompt_toolkit).""" + app = mocker.Mock() + app.layout.current_buffer.text = buffer_text + return app + + +def test_build_preview_questions_disabled_returns_original_list(config): + cz = PreviewCz(config) + questions: list[CzQuestion] = [ + {"type": "input", "name": "subject", "message": "Subject"}, + ] + + out = build_preview_questions(cz, questions, enabled=False, max_length=50) + assert out is questions + + +def test_build_preview_questions_wraps_filter_and_updates_answers_state( + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + config, +): + cz = PreviewCz(config) + + def original_filter(raw: str) -> str: + return raw.strip().upper() + + questions: list[CzQuestion] = [ + { + "type": "input", + "name": "subject", + "message": "Subject", + "filter": original_filter, + } + ] + + enhanced = build_preview_questions(cz, questions, enabled=True, max_length=50) + q = enhanced[0] + assert q["filter"] is not original_filter + assert q["filter"](" hello ") == "HELLO" + + monkeypatch.setattr( + "commitizen.preview_questions.get_app", + lambda: _make_fake_prompt_app(mocker, " hello "), + ) + toolbar_text = q["bottom_toolbar"]() + assert "HELLO" in toolbar_text + assert cz.calls, "cz.message should be called by toolbar rendering" + assert cz.calls[-1]["subject"] == "HELLO" + + +def test_build_preview_questions_adds_toolbar_only_for_supported_types(config): + cz = PreviewCz(config) + questions: list[CzQuestion] = [ + {"type": "input", "name": "a", "message": "A"}, + {"type": "confirm", "name": "b", "message": "B"}, + {"type": "list", "name": "c", "message": "C", "choices": []}, + ] + enhanced = build_preview_questions(cz, questions, enabled=True, max_length=50) + + assert callable(enhanced[0].get("bottom_toolbar")) + assert callable(enhanced[1].get("bottom_toolbar")) + assert "bottom_toolbar" not in enhanced[2] + + +def test_build_preview_questions_adds_validate_only_for_supported_types(config): + cz = PreviewCz(config) + questions: list[CzQuestion] = [ + {"type": "input", "name": "a", "message": "A"}, + {"type": "confirm", "name": "b", "message": "B"}, + {"type": "list", "name": "c", "message": "C", "choices": []}, + ] + enhanced = build_preview_questions(cz, questions, enabled=True, max_length=3) + + assert callable(enhanced[0].get("validate")) + assert "validate" not in enhanced[1] + assert "validate" not in enhanced[2] + + +def test_toolbar_uses_current_buffer_text_and_subject_builder( + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + config, +): + cz = PreviewCz(config) + + monkeypatch.setattr( + "commitizen.preview_questions.get_app", + lambda: _make_fake_prompt_app(mocker, "buffered"), + ) + + questions: list[CzQuestion] = [ + {"type": "input", "name": "subject", "message": "Subject"}, + ] + enhanced = build_preview_questions(cz, questions, enabled=True, max_length=50) + toolbar_text = enhanced[0]["bottom_toolbar"]() + + assert "buffered" in toolbar_text + + +def test_get_current_buffer_text_on_get_app_exception_returns_empty( + monkeypatch: pytest.MonkeyPatch, + config, +): + cz = PreviewCz(config) + monkeypatch.setattr("commitizen.preview_questions.get_app", lambda: 1 / 0) + + questions: list[CzQuestion] = [ + {"type": "input", "name": "subject", "message": "Subject"}, + ] + enhanced = build_preview_questions(cz, questions, enabled=True, max_length=50) + toolbar_text = enhanced[0]["bottom_toolbar"]() + + # With empty buffer text, subject becomes empty -> toolbar still contains counter line + assert toolbar_text.splitlines()[-1].strip().endswith("chars") + + +def test_subject_builder_applies_field_filter_and_handles_filter_exception( + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + config, +): + cz = PreviewCz(config) + + def ok_filter(raw: str) -> str: + return raw.strip() + + def boom_filter(_raw: str) -> str: + raise RuntimeError("boom") + + questions: list[CzQuestion] = [ + {"type": "input", "name": "subject", "message": "Subject", "filter": ok_filter}, + {"type": "input", "name": "scope", "message": "Scope", "filter": boom_filter}, + ] + enhanced = build_preview_questions(cz, questions, enabled=True, max_length=50) + + # Update state for 'subject' (ok filter) + enhanced[0]["filter"](" hi ") + # When rendering toolbar for current field 'scope', subject_builder will apply the + # field filter to the current buffer text; filter exceptions must fallback to raw. + monkeypatch.setattr( + "commitizen.preview_questions.get_app", + lambda: _make_fake_prompt_app(mocker, " SCOPE "), + ) + + # Render toolbar for scope and ensure it still includes subject, and scope raw is used + toolbar_text = enhanced[1]["bottom_toolbar"]() + assert "hi" in toolbar_text + assert cz.calls[-1]["scope"] == " SCOPE " + + +def test_subject_builder_handles_cz_message_exception_returns_empty( + monkeypatch: pytest.MonkeyPatch, + config, + mocker: MockerFixture, +): + class BoomCz(PreviewCz): + def message(self, _answers: Mapping[str, Any]) -> str: + raise RuntimeError("boom") + + cz = BoomCz(config) + + questions: list[CzQuestion] = [ + {"type": "input", "name": "subject", "message": "Subject"}, + ] + enhanced = build_preview_questions(cz, questions, enabled=True, max_length=50) + + monkeypatch.setattr( + "commitizen.interactive_preview.get_terminal_size", + lambda: mocker.Mock(columns=80), + ) + toolbar_text = enhanced[0]["bottom_toolbar"]() + assert toolbar_text.splitlines()[0] == ""