Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
150 changes: 137 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
import questionary.prompts.text
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.keys import Keys

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,25 @@ class CommitArgs(TypedDict, total=False):
retry: bool


def _handle_questionary_prompt(question: CzQuestion, cz_style: Any) -> dict[str, Any]:
"""Handle questionary prompt with error handling."""
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(root_err.__str__())
raise err


def _handle_multiline_fallback(multiline_question: InputQuestion, cz_style: Any) -> dict[str, Any]:
"""Handle fallback to standard behavior if custom multiline approach fails."""
return _handle_questionary_prompt(multiline_question, cz_style)


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

Expand All @@ -66,18 +90,118 @@ 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 = {}

# Handle questions one by one to support custom continuation
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)
elif question["type"] == "input" and question.get("multiline", False):
is_optional = (
question.get("default") == ""
or "skip" in question.get("message", "").lower()
)

if is_optional:
out.info(
"💡 Multiline input:\n Press Enter on empty line to skip, Enter after text for new lines, Alt+Enter to finish"
)
else:
out.info(
"💡 Multiline input:\n Press Enter for new lines and Alt+Enter to finish"
)

# Create custom multiline input with Enter-on-empty behavior for optional fields

multiline_question = question.copy()
multiline_question["multiline"] = True

if is_optional:
# Create custom key bindings for optional fields
bindings = KeyBindings()

@bindings.add(Keys.Enter)
def _(event: KeyPressEvent) -> None:
buffer = event.current_buffer
# If buffer is completely empty, submit
if not buffer.text.strip():
event.app.exit(result=buffer.text)
else:
# If there's text, add new line
buffer.newline()

# Use the text prompt directly with custom bindings
try:
result = questionary.prompts.text.text(
message=question["message"],
multiline=True,
style=cz.style,
key_bindings=bindings,
).ask()

field_name = question["name"]
if result is None:
result = question.get("default", "")

# Apply filter if present
if "filter" in question:
result = question["filter"](result)

answer = {field_name: result}
answers.update(answer)

except Exception:
# Fallback to standard behavior if custom approach fails
answer = _handle_multiline_fallback(multiline_question, cz.style)
answers.update(answer)
else:
# Required fields - don't allow newline on empty first line and show error
bindings = KeyBindings()

@bindings.add(Keys.Enter)
def _(event: KeyPressEvent) -> None:
buffer = event.current_buffer
# If buffer is completely empty (no content at all), show error and don't allow newline
if not buffer.text.strip():
# Show error message with prompt
out.error(
"\n⚠ This field is required. Please enter some content or press Ctrl+C to abort."
)
print("> ", end="", flush=True)
# Don't do anything - require content first
pass
else:
# If there's text, add new line
buffer.newline()

try:
result = questionary.prompts.text.text(
message=question["message"],
multiline=True,
style=cz.style,
key_bindings=bindings,
).ask()

field_name = question["name"]
if result is None:
result = ""

# Apply filter if present
if "filter" in question:
result = question["filter"](result)

answer = {field_name: result}
answers.update(answer)

except Exception:
# Fallback to standard behavior if custom approach fails
answer = _handle_multiline_fallback(multiline_question, cz.style)
answers.update(answer)
else:
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
9 changes: 6 additions & 3 deletions tests/commands/test_commit_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ def test_commit_backup_on_failure(config, mocker: MockFixture):
temp_file = commit_cmd.temp_file
commit_cmd()

prompt_mock.assert_called_once()
# The multiline feature calls prompt multiple times, so don't assert call count
assert prompt_mock.called
error_mock.assert_called_once()
assert os.path.isfile(temp_file)

Expand Down Expand Up @@ -130,7 +131,8 @@ def test_commit_retry_after_failure_no_backup(config, mocker: MockFixture):
commands.Commit(config, {})()

commit_mock.assert_called_with("feat: user created\n\ncloses #21", args="")
prompt_mock.assert_called_once()
# The multiline feature calls prompt multiple times, so don't assert call count
assert prompt_mock.called
success_mock.assert_called_once()


Expand Down Expand Up @@ -175,7 +177,8 @@ def test_commit_retry_after_failure_with_no_retry_works(config, mocker: MockFixt
commit_cmd()

commit_mock.assert_called_with("feat: user created\n\ncloses #21", args="")
prompt_mock.assert_called_once()
# The multiline feature calls prompt multiple times, so don't assert call count
assert prompt_mock.called
success_mock.assert_called_once()
assert not os.path.isfile(temp_file)

Expand Down
Loading
Loading