Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
092ec64
fix(commit): honor message length limit from cli and config
ttw225 Mar 18, 2026
2be6bcb
fix(check): honor message length limit from cli and config
ttw225 Mar 18, 2026
6d7bba1
test(commit): add coverage for message length limit precedence
ttw225 Mar 18, 2026
b0deaf6
test(check): add coverage for message length limit precedence
ttw225 Mar 18, 2026
e61f70c
test(cli-config): add integration tests for message length limit config
ttw225 Mar 18, 2026
e6e7475
fix(types): allow None for message_length_limit cli arg
ttw225 Mar 18, 2026
f5c4b48
refactor(test): apply type-check-only imports
ttw225 Mar 18, 2026
9426b7e
feat(cli,config): add preview flag for live commit subject display
ttw225 Mar 16, 2026
5dc4cec
feat(commit): add interactive live subject preview
ttw225 Mar 16, 2026
912c7fb
test(cz-commit-command): add preview option to commit help and config
ttw225 Mar 16, 2026
8c0a489
docs(commit-preview): add docs demos for commit preview
ttw225 Mar 17, 2026
78ed2a0
refactor(commit-preview): extract commit preview question wiring into…
ttw225 Mar 18, 2026
bc14ba7
refactor(commit): store preview enabled state on commit command
ttw225 Mar 18, 2026
d8ba22d
refactor(preview): simplify preview max length handling
ttw225 Mar 18, 2026
e1e43b0
refactor(question.py): extend question types for preview hooks
ttw225 Mar 18, 2026
efe52fa
test(interactive_preview): add unit tests for interactive preview hel…
ttw225 Mar 18, 2026
e71f887
test(preview_questions): add unit tests for preview question builder
ttw225 Mar 18, 2026
4a825e0
test(commit_command): add test for commit preview question hooks
ttw225 Mar 18, 2026
48fa2fb
docs(commit): add preview to doc
ttw225 Mar 19, 2026
f18c7ae
test(commit): refactor preview tests with mock prompt app
ttw225 Mar 19, 2026
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": ["--preview"],
"action": "store_true",
"help": "Show live first-line (subject) preview while typing.",
},
{
"name": ["--"],
"action": "store_true",
Expand Down
12 changes: 8 additions & 4 deletions commitizen/commands/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
38 changes: 27 additions & 11 deletions commitizen/commands/commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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():
Expand All @@ -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):
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions commitizen/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...] = (
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
"preview": False,
}

MAJOR = "MAJOR"
Expand Down
102 changes: 102 additions & 0 deletions commitizen/interactive_preview.py
Original file line number Diff line number Diff line change
@@ -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
121 changes: 121 additions & 0 deletions commitizen/preview_questions.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading