1use crate::{
7 commit::types::{
8 Body, BreakingChange, CommitMessageError, CommitType, ConventionalCommit, Description,
9 References, Scope,
10 },
11 error::Error,
12 jj::JjExecutor,
13 prompts::prompter::{Prompter, RealPrompts},
14};
15
16#[derive(Debug)]
28pub struct CommitWorkflow<J: JjExecutor, P: Prompter = RealPrompts> {
29 executor: J,
30 prompts: P,
31}
32
33impl<J: JjExecutor> CommitWorkflow<J> {
34 pub fn new(executor: J) -> Self {
38 Self::with_prompts(executor, RealPrompts)
39 }
40}
41
42impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
43 pub fn with_prompts(executor: J, prompts: P) -> Self {
47 Self { executor, prompts }
48 }
49
50 pub async fn run_for_revset(&self, revset: &str) -> Result<(), Error> {
58 if !self.executor.is_repository().await? {
59 return Err(Error::NotARepository);
60 }
61 let _existing_desc = self.executor.get_description(revset).await.ok();
63 let commit_type = self.type_selection()?;
64 loop {
65 let scope = self.scope_input()?;
66 let description = self.description_input()?;
67 let breaking_change = self.breaking_change_input()?;
68 let references = self.references_input()?;
69 let body = self.body_input()?;
70 match self.preview_and_confirm(
71 commit_type,
72 scope,
73 description,
74 breaking_change,
75 body,
76 references,
77 ) {
78 Ok(conventional_commit) => {
79 self.executor
80 .describe(revset, &conventional_commit.to_string())
81 .await?;
82 return Ok(());
83 }
84 Err(Error::InvalidCommitMessage(_)) => {
85 continue;
89 }
90 Err(e) => return Err(e),
91 }
92 }
93 }
94
95 fn type_selection(&self) -> Result<CommitType, Error> {
97 self.prompts.select_commit_type()
98 }
99
100 fn scope_input(&self) -> Result<Scope, Error> {
105 self.prompts.input_scope()
106 }
107
108 fn description_input(&self) -> Result<Description, Error> {
113 self.prompts.input_description()
114 }
115
116 fn breaking_change_input(&self) -> Result<BreakingChange, Error> {
121 self.prompts.input_breaking_change()
122 }
123
124 fn references_input(&self) -> Result<References, Error> {
126 self.prompts.input_references()
127 }
128
129 fn body_input(&self) -> Result<Body, Error> {
131 self.prompts.input_body()
132 }
133
134 fn preview_and_confirm(
139 &self,
140 commit_type: CommitType,
141 scope: Scope,
142 description: Description,
143 breaking_change: BreakingChange,
144 body: Body,
145 references: References,
146 ) -> Result<ConventionalCommit, Error> {
147 let message = ConventionalCommit::format_preview(
149 commit_type,
150 &scope,
151 &description,
152 &breaking_change,
153 &body,
154 &references,
155 );
156
157 let conventional_commit: ConventionalCommit = match ConventionalCommit::new(
159 commit_type,
160 scope.clone(),
161 description.clone(),
162 breaking_change,
163 body,
164 references,
165 ) {
166 Ok(cc) => cc,
167 Err(CommitMessageError::FirstLineTooLong { actual, max }) => {
168 self.prompts.emit_message("❌ Message too long!");
169 self.prompts.emit_message(&format!(
170 "The complete first line must be ≤ {} characters.",
171 max
172 ));
173 self.prompts
174 .emit_message(&format!("Current length: {} characters", actual));
175 self.prompts.emit_message("");
176 self.prompts.emit_message("Formatted message would be:");
177 self.prompts.emit_message(&message);
178 self.prompts.emit_message("");
179 self.prompts
180 .emit_message("Please try again with a shorter scope or description.");
181 return Err(Error::InvalidCommitMessage(format!(
182 "First line too long: {} > {}",
183 actual, max
184 )));
185 }
186 Err(CommitMessageError::InvalidConventionalFormat { reason }) => {
187 return Err(Error::InvalidCommitMessage(format!(
188 "Internal error: generated message failed conventional commit validation: {}",
189 reason
190 )));
191 }
192 };
193
194 let confirmed = self.prompts.confirm_apply(&message)?;
196
197 if confirmed {
198 Ok(conventional_commit)
199 } else {
200 Err(Error::Cancelled)
201 }
202 }
203
204 pub async fn new_revision(&self, revset: &str) -> Result<(), Error> {
205 self.executor.new_revision(revset).await
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::error::Error;
213 use crate::jj::mock::MockJjExecutor;
214 use crate::prompts::mock::MockPrompts;
215
216 #[test]
218 fn workflow_creation() {
219 let mock = MockJjExecutor::new();
220 let workflow = CommitWorkflow::new(mock);
221 assert!(matches!(workflow, CommitWorkflow { .. }));
223 }
224
225 #[tokio::test]
227 async fn workflow_returns_not_a_repository() {
228 let mock = MockJjExecutor::new().with_is_repo_response(Ok(false));
229 let workflow = CommitWorkflow::new(mock);
230 let result: Result<(), Error> = workflow.run_for_revset("@").await;
231 assert!(result.is_err());
232 assert!(matches!(result.unwrap_err(), Error::NotARepository));
233 }
234
235 #[tokio::test]
237 async fn workflow_returns_repository_error() {
238 let mock = MockJjExecutor::new().with_is_repo_response(Err(Error::NotARepository));
239 let workflow = CommitWorkflow::new(mock);
240 let result: Result<(), Error> = workflow.run_for_revset("@").await;
241 assert!(result.is_err());
242 assert!(matches!(result.unwrap_err(), Error::NotARepository));
243 }
244
245 #[test]
247 fn type_selection_returns_valid_type() {
248 let mock_executor = MockJjExecutor::new();
250 let mock_prompts = MockPrompts::new().with_commit_type(CommitType::Feat);
251 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
252
253 let result = workflow.type_selection();
255 assert!(result.is_ok());
256 assert_eq!(result.unwrap(), CommitType::Feat);
257 }
258
259 #[test]
261 fn scope_input_returns_valid_scope() {
262 let mock_executor = MockJjExecutor::new();
263 let mock_prompts = MockPrompts::new().with_scope(Scope::parse("test").unwrap());
264 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
265
266 let result = workflow.scope_input();
267 assert!(result.is_ok());
268 assert_eq!(result.unwrap(), Scope::parse("test").unwrap());
269 }
270
271 #[test]
273 fn description_input_returns_valid_description() {
274 let mock_executor = MockJjExecutor::new();
275 let mock_prompts = MockPrompts::new().with_description(Description::parse("test").unwrap());
276 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
277
278 let result = workflow.description_input();
279 assert!(result.is_ok());
280 assert_eq!(result.unwrap(), Description::parse("test").unwrap());
281 }
282
283 #[test]
285 fn preview_and_confirm_returns_conventional_commit() {
286 let mock_executor = MockJjExecutor::new();
287 let mock_prompts = MockPrompts::new().with_confirm(true);
288 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
289
290 let commit_type = CommitType::Feat;
291 let scope = Scope::empty();
292 let description = Description::parse("test description").unwrap();
293 let breaking_change = BreakingChange::No;
294 let body = Body::default();
295 let references = References::default();
296 let result = workflow.preview_and_confirm(
297 commit_type,
298 scope,
299 description,
300 breaking_change,
301 body,
302 references,
303 );
304 assert!(result.is_ok());
305 }
306
307 #[tokio::test]
309 async fn workflow_handles_describe_error() {
310 let mock = MockJjExecutor::new()
312 .with_is_repo_response(Ok(true))
313 .with_describe_response(Err(Error::RepositoryLocked));
314
315 assert!(mock.is_repository().await.is_ok());
317 assert!(mock.describe("@", "test").await.is_err());
318
319 let working_mock = MockJjExecutor::new();
321 let workflow = CommitWorkflow::new(working_mock);
322 assert!(matches!(workflow, CommitWorkflow { .. }));
325 }
326
327 #[test]
329 fn workflow_implements_debug() {
330 let mock = MockJjExecutor::new();
331 let workflow = CommitWorkflow::new(mock);
332 let debug_output = format!("{:?}", workflow);
333 assert!(debug_output.contains("CommitWorkflow"));
334 }
335
336 #[tokio::test]
338 async fn test_complete_workflow_happy_path() {
339 let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
341
342 let mock_prompts = MockPrompts::new()
344 .with_commit_type(CommitType::Feat)
345 .with_scope(Scope::empty())
346 .with_description(Description::parse("add new feature").unwrap())
347 .with_breaking_change(BreakingChange::Yes)
348 .with_references(References::default())
349 .with_body(Body::default())
350 .with_confirm(true);
351
352 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
354
355 let result: Result<(), Error> = workflow.run_for_revset("@").await;
357 assert!(result.is_ok());
358 }
359
360 #[tokio::test]
362 async fn test_workflow_cancellation_at_type_selection() {
363 let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
364 let mock_prompts = MockPrompts::new().with_error(Error::Cancelled);
365
366 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
367 let result: Result<(), Error> = workflow.run_for_revset("@").await;
368
369 assert!(result.is_err());
370 assert!(matches!(result.unwrap_err(), Error::Cancelled));
371 }
372
373 #[tokio::test]
375 async fn test_workflow_cancellation_at_confirmation() {
376 let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
377 let mock_prompts = MockPrompts::new()
378 .with_commit_type(CommitType::Fix)
379 .with_scope(Scope::parse("api").unwrap())
380 .with_description(Description::parse("fix bug").unwrap())
381 .with_breaking_change(BreakingChange::No)
382 .with_references(References::default())
383 .with_body(Body::default())
384 .with_confirm(false); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
387 let result: Result<(), Error> = workflow.run_for_revset("@").await;
388
389 assert!(result.is_err());
390 assert!(matches!(result.unwrap_err(), Error::Cancelled));
391 }
392
393 #[tokio::test]
398 async fn test_workflow_line_length_validation() {
399 let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
400
401 let mock_prompts = MockPrompts::new()
402 .with_commit_type(CommitType::Feat)
403 .with_scope(Scope::parse("very-long-scope-name").unwrap())
405 .with_description(Description::parse("a".repeat(45)).unwrap())
406 .with_breaking_change(BreakingChange::No)
407 .with_references(References::default())
408 .with_body(Body::default())
409 .with_scope(Scope::empty())
411 .with_description(Description::parse("short description").unwrap())
412 .with_breaking_change(BreakingChange::No)
413 .with_references(References::default())
414 .with_body(Body::default())
415 .with_confirm(true);
416
417 let mock_prompts_handle = mock_prompts.clone();
419 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
420 let result: Result<(), Error> = workflow.run_for_revset("@").await;
421
422 assert!(
424 result.is_ok(),
425 "Workflow should succeed after retry, got: {:?}",
426 result
427 );
428
429 let messages = mock_prompts_handle.emitted_messages();
432 assert!(
433 messages.iter().any(|m| m.contains("too long")),
434 "Expected a 'too long' message, got: {:?}",
435 messages
436 );
437 assert!(
438 messages.iter().any(|m| m.contains("72")),
439 "Expected a message about the 72-char limit, got: {:?}",
440 messages
441 );
442 }
443
444 #[tokio::test]
446 async fn test_workflow_invalid_scope() {
447 let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
448
449 let mock_prompts = MockPrompts::new()
451 .with_commit_type(CommitType::Docs)
452 .with_error(Error::InvalidScope(
453 "Invalid characters in scope".to_string(),
454 ));
455
456 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
457 let result: Result<(), Error> = workflow.run_for_revset("@").await;
458
459 assert!(result.is_err());
460 assert!(matches!(result.unwrap_err(), Error::InvalidScope(_)));
461 }
462
463 #[tokio::test]
465 async fn test_workflow_invalid_description() {
466 let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
467
468 let mock_prompts = MockPrompts::new()
469 .with_commit_type(CommitType::Refactor)
470 .with_scope(Scope::empty())
471 .with_error(Error::InvalidDescription(
472 "Description cannot be empty".to_string(),
473 ));
474
475 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
476 let result: Result<(), Error> = workflow.run_for_revset("@").await;
477
478 assert!(result.is_err());
479 assert!(matches!(result.unwrap_err(), Error::InvalidDescription(_)));
480 }
481
482 #[test]
484 fn test_mock_prompts_track_calls() {
485 let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
486 let mock_prompts = MockPrompts::new()
487 .with_commit_type(CommitType::Feat)
488 .with_scope(Scope::empty())
489 .with_description(Description::parse("test").unwrap())
490 .with_confirm(true);
491
492 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
493 assert!(matches!(workflow, CommitWorkflow { .. }));
495 }
496
497 #[tokio::test]
499 async fn test_all_commit_types() {
500 let _mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
501
502 for commit_type in CommitType::all() {
503 let mock_prompts = MockPrompts::new()
504 .with_commit_type(*commit_type)
505 .with_scope(Scope::empty())
506 .with_description(Description::parse("test").unwrap())
507 .with_breaking_change(BreakingChange::Yes)
508 .with_references(References::default())
509 .with_body(Body::default())
510 .with_confirm(true);
511
512 let workflow = CommitWorkflow::with_prompts(
513 MockJjExecutor::new().with_is_repo_response(Ok(true)),
514 mock_prompts,
515 );
516 let result: Result<(), Error> = workflow.run_for_revset("@").await;
517 assert!(result.is_ok(), "Failed for commit type: {:?}", commit_type);
518 }
519 }
520
521 #[tokio::test]
523 async fn test_various_scope_formats() {
524 let _mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
525
526 let mock_prompts = MockPrompts::new()
528 .with_commit_type(CommitType::Feat)
529 .with_scope(Scope::empty())
530 .with_description(Description::parse("test").unwrap())
531 .with_breaking_change(BreakingChange::Yes)
532 .with_references(References::default())
533 .with_body(Body::default())
534 .with_confirm(true);
535
536 let workflow = CommitWorkflow::with_prompts(
537 MockJjExecutor::new().with_is_repo_response(Ok(true)),
538 mock_prompts,
539 );
540 {
541 let result: Result<(), Error> = workflow.run_for_revset("@").await;
542 assert!(result.is_ok());
543 }
544
545 let mock_prompts = MockPrompts::new()
547 .with_commit_type(CommitType::Feat)
548 .with_scope(Scope::parse("api").unwrap())
549 .with_description(Description::parse("test").unwrap())
550 .with_breaking_change(BreakingChange::No)
551 .with_references(References::default())
552 .with_body(Body::default())
553 .with_confirm(true);
554
555 let workflow = CommitWorkflow::with_prompts(
556 MockJjExecutor::new().with_is_repo_response(Ok(true)),
557 mock_prompts,
558 );
559 {
560 let result: Result<(), Error> = workflow.run_for_revset("@").await;
561 assert!(result.is_ok());
562 }
563 }
564
565 #[test]
567 fn workflow_works_with_trait_objects() {
568 let mock_executor = MockJjExecutor::new();
569 let mock_prompts = MockPrompts::new()
570 .with_commit_type(CommitType::Feat)
571 .with_scope(Scope::empty())
572 .with_description(Description::parse("test").unwrap())
573 .with_confirm(true);
574
575 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
576 assert!(matches!(workflow, CommitWorkflow { .. }));
577 }
578
579 #[test]
588 fn preview_and_confirm_forwards_breaking_change_yes() {
589 let mock_executor = MockJjExecutor::new();
590 let mock_prompts = MockPrompts::new().with_confirm(true);
591 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
592
593 let result = workflow.preview_and_confirm(
594 CommitType::Feat,
595 Scope::empty(),
596 Description::parse("remove old API").unwrap(),
597 BreakingChange::Yes,
598 Body::default(),
599 References::default(),
600 );
601
602 assert!(result.is_ok(), "expected Ok, got: {:?}", result);
603 let message = result.unwrap().to_string();
604 assert!(
605 message.contains("feat!:"),
606 "expected '!' marker in described message, got: {:?}",
607 message,
608 );
609 }
610
611 #[test]
615 fn preview_and_confirm_forwards_breaking_change_with_note() {
616 let mock_executor = MockJjExecutor::new();
617 let mock_prompts = MockPrompts::new().with_confirm(true);
618 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
619
620 let breaking_change: BreakingChange = "removes legacy endpoint".into();
621 let result = workflow.preview_and_confirm(
622 CommitType::Feat,
623 Scope::empty(),
624 Description::parse("drop legacy API").unwrap(),
625 breaking_change,
626 Body::default(),
627 References::default(),
628 );
629
630 assert!(result.is_ok(), "expected Ok, got: {:?}", result);
631 let message = result.unwrap().to_string();
632 assert!(
633 message.contains("feat!:"),
634 "expected '!' header marker in message, got: {:?}",
635 message,
636 );
637 assert!(
638 message.contains("BREAKING CHANGE:"),
639 "expected BREAKING CHANGE footer in message, got: {:?}",
640 message,
641 );
642 }
643
644 #[tokio::test]
651 async fn full_workflow_describes_commit_with_breaking_change_marker() {
652 let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
653 let mock_prompts = MockPrompts::new()
654 .with_commit_type(CommitType::Feat)
655 .with_scope(Scope::empty())
656 .with_description(Description::parse("remove old API").unwrap())
657 .with_breaking_change(BreakingChange::Yes)
658 .with_references(References::default())
659 .with_body(Body::default())
660 .with_confirm(true);
661
662 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
663 let result: Result<(), Error> = workflow.run_for_revset("@").await;
664
665 assert!(
666 result.is_ok(),
667 "expected workflow to succeed, got: {:?}",
668 result
669 );
670
671 let messages = workflow.executor.describe_messages();
672 assert_eq!(messages.len(), 1, "expected exactly one describe() call");
673 assert!(
674 messages[0].contains("feat!:"),
675 "expected '!' marker in the described message, got: {:?}",
676 messages[0],
677 );
678 }
679
680 #[test]
690 fn preview_and_confirm_forwards_body() {
691 let mock_executor = MockJjExecutor::new();
692 let mock_prompts = MockPrompts::new().with_confirm(true);
693 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
694
695 let result = workflow.preview_and_confirm(
696 CommitType::Feat,
697 Scope::empty(),
698 Description::parse("add feature").unwrap(),
699 BreakingChange::No,
700 Body::from("This explains the change."),
701 References::default(),
702 );
703
704 assert!(result.is_ok(), "expected Ok, got: {:?}", result);
705 assert!(
706 result
707 .unwrap()
708 .to_string()
709 .contains("This explains the change."),
710 "body must appear in the commit message"
711 );
712 }
713
714 #[test]
718 fn preview_and_confirm_forwards_body_with_breaking_change() {
719 let mock_executor = MockJjExecutor::new();
720 let mock_prompts = MockPrompts::new().with_confirm(true);
721 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
722
723 let result = workflow.preview_and_confirm(
724 CommitType::Feat,
725 Scope::empty(),
726 Description::parse("drop legacy API").unwrap(),
727 "removes legacy endpoint".into(),
728 Body::from("The endpoint was deprecated in v2."),
729 References::default(),
730 );
731
732 assert!(result.is_ok(), "expected Ok, got: {:?}", result);
733 let message = result.unwrap().to_string();
734 assert!(
735 message.contains("The endpoint was deprecated in v2."),
736 "body must appear in the commit message, got: {message:?}"
737 );
738 assert!(
739 message.contains("BREAKING CHANGE: removes legacy endpoint"),
740 "breaking change footer must still be present, got: {message:?}"
741 );
742 }
743
744 #[tokio::test]
749 async fn full_workflow_describes_commit_with_body() {
750 let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
751 let mock_prompts = MockPrompts::new()
752 .with_commit_type(CommitType::Feat)
753 .with_scope(Scope::empty())
754 .with_description(Description::parse("add feature").unwrap())
755 .with_breaking_change(BreakingChange::No)
756 .with_references(References::default())
757 .with_body(Body::from("This explains the change."))
758 .with_confirm(true);
759
760 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
761 let result: Result<(), Error> = workflow.run_for_revset("@").await;
762
763 assert!(
764 result.is_ok(),
765 "expected workflow to succeed, got: {:?}",
766 result
767 );
768
769 let messages = workflow.executor.describe_messages();
770 assert_eq!(messages.len(), 1, "expected exactly one describe() call");
771 assert!(
772 messages[0].contains("This explains the change."),
773 "body must appear in the described commit, got: {:?}",
774 messages[0]
775 );
776 }
777
778 #[tokio::test]
782 async fn full_workflow_with_no_body_succeeds() {
783 let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
784 let mock_prompts = MockPrompts::new()
785 .with_commit_type(CommitType::Fix)
786 .with_scope(Scope::empty())
787 .with_description(Description::parse("fix crash").unwrap())
788 .with_breaking_change(BreakingChange::No)
789 .with_references(References::default())
790 .with_body(Body::default())
791 .with_confirm(true);
792
793 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
794 let result: Result<(), Error> = workflow.run_for_revset("@").await;
795
796 assert!(
797 result.is_ok(),
798 "expected workflow to succeed, got: {:?}",
799 result
800 );
801
802 let messages = workflow.executor.describe_messages();
803 assert_eq!(messages.len(), 1);
804 assert_eq!(messages[0], "fix: fix crash");
805 }
806
807 #[tokio::test]
809 async fn workflow_new_revision_records_revset() {
810 let mock_executor = MockJjExecutor::new();
811 let workflow = CommitWorkflow::new(mock_executor);
812
813 let result = workflow.new_revision("@").await;
814 assert!(result.is_ok());
815
816 let calls = workflow.executor.new_revision_calls();
817 assert_eq!(calls, vec!["@"]);
818 }
819
820 #[tokio::test]
822 async fn workflow_new_revision_propagates_error() {
823 let mock_executor =
824 MockJjExecutor::new().with_new_revision_response(Err(Error::RepositoryLocked));
825 let workflow = CommitWorkflow::new(mock_executor);
826
827 let result = workflow.new_revision("@").await;
828 assert!(result.is_err());
829 assert!(matches!(result.unwrap_err(), Error::RepositoryLocked));
830 }
831
832 #[tokio::test]
836 async fn workflow_describe_then_new_revision() {
837 let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
838 let mock_prompts = MockPrompts::new()
839 .with_commit_type(CommitType::Feat)
840 .with_scope(Scope::empty())
841 .with_description(Description::parse("add feature").unwrap())
842 .with_breaking_change(BreakingChange::No)
843 .with_references(References::default())
844 .with_body(Body::default())
845 .with_confirm(true);
846
847 let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
848
849 workflow.run_for_revset("@").await.expect("describe failed");
850 workflow
851 .new_revision("@")
852 .await
853 .expect("new_revision failed");
854
855 let messages = workflow.executor.describe_messages();
856 assert_eq!(messages.len(), 1);
857 assert!(messages[0].contains("feat:"));
858
859 let calls = workflow.executor.new_revision_calls();
860 assert_eq!(calls, vec!["@"]);
861 }
862}