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
112 changes: 99 additions & 13 deletions commitizen/commands/commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@
import subprocess
import tempfile
from pathlib import Path
from typing import TypedDict
from typing import Any, TypedDict

import questionary
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.keys import Keys
from prompt_toolkit.styles import Style

from commitizen import factory, git, out
from commitizen.config import BaseConfig
Expand All @@ -26,6 +30,7 @@
NothingToCommitError,
)
from commitizen.git import smart_open
from commitizen.question import CzQuestion, InputQuestion


class CommitArgs(TypedDict, total=False):
Expand All @@ -40,6 +45,91 @@ class CommitArgs(TypedDict, total=False):
retry: bool


def _handle_questionary_prompt(question: CzQuestion, cz_style: Style) -> dict[str, Any]:
"""Handle questionary prompt with multiline and error handling."""
if question["type"] == "input" and question.get("multiline", False):
return _handle_multiline_question(question, cz_style)

try:
answer = questionary.prompt([question], style=cz_style)
if not answer:
raise NoAnswersError()
return answer
except ValueError as err:
root_err = err.__context__
if isinstance(root_err, CzException):
raise CustomError(str(root_err))
raise err


def _handle_multiline_question(
question: InputQuestion, cz_style: Style
) -> dict[str, Any]:
"""Handle multiline input questions."""
is_optional = (
question.get("default") == ""
or "skip" in question.get("message", "").lower()
or "[enter] to skip" in question.get("message", "").lower()
)

guidance = (
"💡 Press Enter on empty line to skip, Alt+Enter to finish"
if is_optional
else "💡 Press Alt+Enter to finish"
)
out.info(guidance)

def _handle_key_press(event: KeyPressEvent, is_finish_key: bool) -> None:
buffer = event.current_buffer
is_empty = not buffer.text.strip()

if is_empty:
if is_optional and not is_finish_key:
event.app.exit(result="")
elif not is_optional:
out.error(
"⚠ This field is required. Please enter some content or press Ctrl+C to abort."
)
out.line("> ", end="", flush=True)
else:
event.app.exit(result=buffer.text)
else:
if is_finish_key:
event.app.exit(result=buffer.text)
else:
buffer.newline()

bindings = KeyBindings()

@bindings.add(Keys.Enter)
def _(event: KeyPressEvent) -> None:
_handle_key_press(event, is_finish_key=False)

@bindings.add(Keys.Escape, Keys.Enter)
def _(event: KeyPressEvent) -> None:
_handle_key_press(event, is_finish_key=True)

result = questionary.text(
message=question["message"],
multiline=True,
style=cz_style,
key_bindings=bindings,
).unsafe_ask()

if result is None:
result = question.get("default", "")

if "filter" in question:
try:
result = question["filter"](result)
except Exception as e:
out.error(f"⚠ {str(e)}")
out.line("> ", end="", flush=True)
return _handle_multiline_question(question, cz_style)

return {question["name"]: result}


class Commit:
"""Show prompt for the user to create a guided commit."""

Expand All @@ -66,18 +156,14 @@ def _prompt_commit_questions(self) -> str:
# Prompt user for the commit message
cz = self.cz
questions = cz.questions()
for question in (q for q in questions if q["type"] == "list"):
question["use_shortcuts"] = self.config.settings["use_shortcuts"]
try:
answers = questionary.prompt(questions, style=cz.style)
except ValueError as err:
root_err = err.__context__
if isinstance(root_err, CzException):
raise CustomError(root_err.__str__())
raise err

if not answers:
raise NoAnswersError()
answers = {}

for question in questions:
if question["type"] == "list":
question["use_shortcuts"] = self.config.settings["use_shortcuts"]

answer = _handle_questionary_prompt(question, cz.style)
answers.update(answer)

message = cz.message(answers)
message_len = len(message.partition("\n")[0].strip())
Expand Down
25 changes: 10 additions & 15 deletions commitizen/cz/conventional_commits/conventional_commits.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from commitizen import defaults
from commitizen.cz.base import BaseCommitizen
from commitizen.cz.utils import multiple_line_breaker, required_validator
from commitizen.cz.utils import required_validator
from commitizen.question import CzQuestion

__all__ = ["ConventionalCommitsCz"]
Expand Down Expand Up @@ -109,26 +109,23 @@ def questions(self) -> list[CzQuestion]:
{
"type": "input",
"name": "scope",
"message": (
"What is the scope of this change? (class or file name): (press [enter] to skip)\n"
),
"message": "What is the scope of this change? (class or file name): (press [enter] to skip)",
"filter": _parse_scope,
"multiline": True,
},
{
"type": "input",
"name": "subject",
"filter": _parse_subject,
"message": (
"Write a short and imperative summary of the code changes: (lower case and no period)\n"
),
"message": "Write a short and imperative summary of the code changes: (lower case and no period)",
"multiline": True,
},
{
"type": "input",
"name": "body",
"message": (
"Provide additional contextual information about the code changes: (press [enter] to skip)\n"
),
"filter": multiple_line_breaker,
"message": "Provide additional contextual information about the code changes:\n(Use multiline input or [Enter] to skip)",
"multiline": True,
"default": "",
},
{
"type": "confirm",
Expand All @@ -139,10 +136,8 @@ def questions(self) -> list[CzQuestion]:
{
"type": "input",
"name": "footer",
"message": (
"Footer. Information about Breaking Changes and "
"reference issues that this commit closes: (press [enter] to skip)\n"
),
"message": "Footer. Information about Breaking Changes and reference issues that this commit closes: (press [enter] to skip)",
"multiline": True,
},
]

Expand Down
2 changes: 2 additions & 0 deletions commitizen/question.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class InputQuestion(TypedDict, total=False):
name: str
message: str
filter: Callable[[str], str]
multiline: bool
default: str


class ConfirmQuestion(TypedDict):
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ This standardization makes your commit history more readable and meaningful, whi
### Features

- Interactive CLI for standardized commits with default [Conventional Commits][conventional_commits] support
- **Enhanced multiline input** with smart behavior for required and optional fields
- Intelligent [version bumping](https://commitizen-tools.github.io/commitizen/commands/bump/) using [Semantic Versioning][semver]
- Automatic [keep a changelog][keepchangelog] generation
- Built-in commit validation with pre-commit hooks
Expand Down
38 changes: 38 additions & 0 deletions docs/commands/commit.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,44 @@ cz commit --write-message-to-file COMMIT_MSG_FILE

This can be combined with `--dry-run` to only write the message without creating a commit. This is particularly useful for [automatically preparing commit messages](../tutorials/auto_prepare_commit_message.md).

## Multiline Input Support

Commitizen now supports enhanced multiline input for commit messages, making it easier to create detailed, well-structured commits.

### How It Works

When prompted for input during `cz commit`, you can now use multiline input with smart behavior:

#### For Optional Fields (scope, body, footer)
- **Enter on empty line** → Skips the field
- **Enter after typing content** → Adds a new line for multiline input
- **Alt+Enter** → Finishes and submits the input

#### For Required Fields (subject)
- **Enter on empty line** → Shows error message with options:
```
⚠ This field is required. Please enter some content or press Ctrl+C to abort.
>
```
- **Enter after typing content** → Adds a new line for multiline input
- **Alt+Enter** → Finishes and submits the input
- **Ctrl+C** → Aborts the commit session

### Example Usage

```sh
cz commit
```

During the interactive process:

1. **Commit Type**: Select from the list (e.g., `feat`, `fix`, `docs`)
2. **Scope** (optional): Press Enter to skip, or type scope and use Enter for multiline
3. **Subject** (required): Must enter content, can use Enter for multiline, Alt+Enter to finish
4. **Body** (optional): Press Enter to skip, or add detailed description with multiline support
5. **Breaking Change**: Yes/No confirmation
6. **Footer** (optional): Press Enter to skip, or add references/notes with multiline support

## Advanced Features

### Git Command Options
Expand Down
1 change: 0 additions & 1 deletion docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,6 @@ commitizen:
| `choices` | `list` | `None` | (OPTIONAL) The choices when `type = list` or `type = select`. Either use a list of values or a list of dictionaries with `name` and `value` keys. Keyboard shortcuts can be defined via `key`. See examples above. |
| `default` | `Any` | `None` | (OPTIONAL) The default value for this question. |
| `filter` | `str` | `None` | (OPTIONAL) Validator for user's answer. **(Work in Progress)** |
| `multiline` | `bool` | `False` | (OPTIONAL) Enable multiline support when `type = input`. |
| `use_search_filter` | `bool` | `False` | (OPTIONAL) Enable search/filter functionality for list/select type questions. This allows users to type and filter through the choices. |
| `use_jk_keys` | `bool` | `True` | (OPTIONAL) Enable/disable j/k keys for navigation in list/select type questions. Set to false if you prefer arrow keys only. |

Expand Down
Loading
Loading