feat: implement breaking change input
This commit is contained in:
@@ -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 2–4 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user