feat: implement breaking change input

This commit is contained in:
2026-03-09 22:57:35 +01:00
parent e794251b98
commit 3e0d82de9a
14 changed files with 976 additions and 143 deletions

View File

@@ -4,7 +4,9 @@
//! creating a conventional commit message using interactive prompts.
use crate::{
commit::types::{CommitMessageError, CommitType, ConventionalCommit, Description, Scope},
commit::types::{
BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description, Scope,
},
error::Error,
jj::JjExecutor,
prompts::prompter::{Prompter, RealPrompts},
@@ -52,30 +54,19 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
/// - Repository operation fails
/// - Message validation fails
pub async fn run(&self) -> Result<(), Error> {
// Verify we're in a jj repository
if !self.executor.is_repository().await? {
return Err(Error::NotARepository);
}
// Step 1: Select commit type (kept across retries)
let commit_type = self.type_selection().await?;
// Steps 24 loop: re-prompt scope and description when the combined
// first line would exceed 72 characters (issue 3.4).
loop {
// Step 2: Input scope (optional)
let scope = self.scope_input().await?;
// Step 3: Input description (required)
let description = self.description_input().await?;
// Step 4: Preview and confirm
let breaking_change = self.breaking_change_input().await?;
match self
.preview_and_confirm(commit_type, scope, description)
.preview_and_confirm(commit_type, scope, description, breaking_change)
.await
{
Ok(conventional_commit) => {
// Step 5: Apply the message
self.executor
.describe(&conventional_commit.to_string())
.await?;
@@ -99,35 +90,49 @@ impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
/// Prompt user to input an optional scope
///
/// Returns Ok(Scope) with the validated scope, or Error::Cancelled if user cancels
/// Returns Ok(Scope) with the validated scope, or
/// Error::Cancelled if user cancels
async fn scope_input(&self) -> Result<Scope, Error> {
self.prompts.input_scope()
}
/// Prompt user to input a required description
///
/// Returns Ok(Description) with the validated description, or Error::Cancelled if user cancels
/// Returns Ok(Description) with the validated description, or
/// Error::Cancelled if user cancels
async fn description_input(&self) -> Result<Description, Error> {
self.prompts.input_description()
}
/// Prompt user for breaking change
///
/// Returns Ok(BreakingChange) with the validated breaking change,
/// or Error::Cancel if user cancels
async fn breaking_change_input(&self) -> Result<BreakingChange, Error> {
self.prompts.input_breaking_change()
}
/// Preview the formatted conventional commit message and get user confirmation
///
/// This method also validates that the complete first line doesn't exceed 72 characters
/// This method also validates that the complete first line
/// doesn't exceed 72 characters
async fn preview_and_confirm(
&self,
commit_type: CommitType,
scope: Scope,
description: Description,
breaking_change: BreakingChange,
) -> Result<ConventionalCommit, Error> {
// Format the message for preview
let message = ConventionalCommit::format_preview(commit_type, &scope, &description);
let message =
ConventionalCommit::format_preview(commit_type, &scope, &description, &breaking_change);
// Try to build the conventional commit (this validates the 72-char limit)
let conventional_commit: ConventionalCommit = match ConventionalCommit::new(
commit_type,
scope.clone(),
description.clone(),
breaking_change,
) {
Ok(cc) => cc,
Err(CommitMessageError::FirstLineTooLong { actual, max }) => {
@@ -252,9 +257,9 @@ mod tests {
let commit_type = CommitType::Feat;
let scope = Scope::empty();
let description = Description::parse("test description").unwrap();
let breaking_change = BreakingChange::No;
let result = workflow
.preview_and_confirm(commit_type, scope, description)
.preview_and_confirm(commit_type, scope, description, breaking_change)
.await;
assert!(result.is_ok());
}
@@ -299,6 +304,7 @@ mod tests {
.with_commit_type(CommitType::Feat)
.with_scope(Scope::empty())
.with_description(Description::parse("add new feature").unwrap())
.with_breaking_change(BreakingChange::Yes)
.with_confirm(true);
// Create workflow with both mocks
@@ -330,6 +336,7 @@ mod tests {
.with_commit_type(CommitType::Fix)
.with_scope(Scope::parse("api").unwrap())
.with_description(Description::parse("fix bug").unwrap())
.with_breaking_change(BreakingChange::No)
.with_confirm(false); // User cancels at confirmation
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
@@ -352,9 +359,11 @@ mod tests {
// First iteration: scope + description exceed 72 chars combined
.with_scope(Scope::parse("very-long-scope-name").unwrap())
.with_description(Description::parse("a".repeat(45)).unwrap())
.with_breaking_change(BreakingChange::No)
// Second iteration: short enough to succeed
.with_scope(Scope::empty())
.with_description(Description::parse("short description").unwrap())
.with_breaking_change(BreakingChange::No)
.with_confirm(true);
// Clone before moving into workflow so we can inspect emitted messages after
@@ -447,6 +456,7 @@ mod tests {
.with_commit_type(*commit_type)
.with_scope(Scope::empty())
.with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::Yes)
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(
@@ -468,6 +478,7 @@ mod tests {
.with_commit_type(CommitType::Feat)
.with_scope(Scope::empty())
.with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::Yes)
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(
@@ -484,6 +495,7 @@ mod tests {
.with_commit_type(CommitType::Feat)
.with_scope(Scope::parse("api").unwrap())
.with_description(Description::parse("test").unwrap())
.with_breaking_change(BreakingChange::No)
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(
@@ -509,4 +521,99 @@ mod tests {
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
assert!(matches!(workflow, CommitWorkflow { .. }));
}
/// Preview_and_confirm must forward BreakingChange::Yes to
/// ConventionalCommit::new(), producing a commit whose string
/// contains '!'.
///
/// Before the fix the parameter was ignored and
/// BreakingChange::No was hard-coded, so a confirmed
/// breaking-change commit was silently applied without the '!'
/// marker.
#[tokio::test]
async fn preview_and_confirm_forwards_breaking_change_yes() {
let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result = workflow
.preview_and_confirm(
CommitType::Feat,
Scope::empty(),
Description::parse("remove old API").unwrap(),
BreakingChange::Yes,
)
.await;
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
let message = result.unwrap().to_string();
assert!(
message.contains("feat!:"),
"expected '!' marker in described message, got: {:?}",
message,
);
}
/// Preview_and_confirm must forward BreakingChange::WithNote,
/// producing a commit with both the '!' header marker and the
/// BREAKING CHANGE footer.
#[tokio::test]
async fn preview_and_confirm_forwards_breaking_change_with_note() {
let mock_executor = MockJjExecutor::new();
let mock_prompts = MockPrompts::new().with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let breaking_change: BreakingChange = "removes legacy endpoint".into();
let result = workflow
.preview_and_confirm(
CommitType::Feat,
Scope::empty(),
Description::parse("drop legacy API").unwrap(),
breaking_change,
)
.await;
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
let message = result.unwrap().to_string();
assert!(
message.contains("feat!:"),
"expected '!' header marker in message, got: {:?}",
message,
);
assert!(
message.contains("BREAKING CHANGE:"),
"expected BREAKING CHANGE footer in message, got: {:?}",
message,
);
}
/// The message passed to executor.describe() must include the '!'
/// marker when the user selects a breaking change.
///
/// This test exercises the full run() path and inspects what was
/// actually handed to the jj executor, which is the authoritative
/// check that the described commit is correct.
#[tokio::test]
async fn full_workflow_describes_commit_with_breaking_change_marker() {
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
let mock_prompts = MockPrompts::new()
.with_commit_type(CommitType::Feat)
.with_scope(Scope::empty())
.with_description(Description::parse("remove old API").unwrap())
.with_breaking_change(BreakingChange::Yes)
.with_confirm(true);
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await;
assert!(result.is_ok(), "expected workflow to succeed, got: {:?}", result);
let messages = workflow.executor.describe_messages();
assert_eq!(messages.len(), 1, "expected exactly one describe() call");
assert!(
messages[0].contains("feat!:"),
"expected '!' marker in the described message, got: {:?}",
messages[0],
);
}
}