1use anyhow::{Context, Result};
4use clap::Parser;
5use tracing::debug;
6
7use crate::data::amendments::AmendmentFile;
8use crate::data::RepositoryView;
9
10#[derive(Parser)]
12pub struct TwiddleCommand {
13 #[arg(value_name = "COMMIT_RANGE")]
15 pub commit_range: Option<String>,
16
17 #[arg(long)]
19 pub auto_apply: bool,
20
21 #[arg(long)]
23 pub allow_pushed: bool,
24
25 #[arg(long, value_name = "FILE")]
27 pub save_only: Option<String>,
28
29 #[arg(long, default_value = "true")]
31 pub use_context: bool,
32
33 #[arg(long)]
35 pub context_dir: Option<std::path::PathBuf>,
36
37 #[arg(long)]
39 pub work_context: Option<String>,
40
41 #[arg(long)]
43 pub branch_context: Option<String>,
44
45 #[arg(long)]
47 pub no_context: bool,
48
49 #[arg(long, default_value = "4")]
51 pub concurrency: usize,
52
53 #[arg(long, hide = true)]
55 pub batch_size: Option<usize>,
56
57 #[arg(long)]
59 pub no_coherence: bool,
60
61 #[arg(long)]
64 pub no_ai: bool,
65
66 #[arg(long, conflicts_with = "refine")]
69 pub fresh: bool,
70
71 #[arg(long, conflicts_with = "fresh")]
74 pub refine: bool,
75
76 #[arg(long)]
78 pub check: bool,
79
80 #[arg(long)]
82 pub quiet: bool,
83
84 #[command(flatten)]
86 pub ai: crate::cli::ai_backend_args::AiBackendArgs,
87}
88
89impl TwiddleCommand {
90 fn is_fresh(&self) -> bool {
93 !self.refine
94 }
95
96 pub async fn execute(mut self, repo: Option<&std::path::Path>) -> Result<()> {
98 self.ai.apply();
99
100 if let Some(bs) = self.batch_size {
102 eprintln!("warning: --batch-size is deprecated; use --concurrency instead");
103 self.concurrency = bs;
104 }
105
106 let repo_root = match repo {
112 Some(p) => p.to_path_buf(),
113 None => std::env::current_dir().context("Failed to determine current directory")?,
114 };
115 let repo_root = repo_root.as_path();
116
117 if self.no_ai {
119 return self.execute_no_ai(repo_root).await;
120 }
121
122 let ai_info = crate::utils::check_ai_command_prerequisites(None, repo_root)?;
128 println!(
129 "✓ {} credentials verified (model: {})",
130 ai_info.provider, ai_info.model
131 );
132
133 crate::utils::check_working_directory_clean_at(repo_root)?;
135 println!("✓ Working directory is clean");
136
137 let claude_client = crate::claude::create_default_claude_client(None, None).await?;
139
140 self.execute_with_client(repo_root, claude_client).await
141 }
142
143 pub(crate) async fn execute_with_client(
150 self,
151 repo_root: &std::path::Path,
152 claude_client: crate::claude::client::ClaudeClient,
153 ) -> Result<()> {
154 let use_contextual = self.use_context && !self.no_context;
156
157 if use_contextual {
158 println!(
159 "🪄 Starting AI-powered commit message improvement with contextual intelligence..."
160 );
161 } else {
162 println!("🪄 Starting AI-powered commit message improvement...");
163 }
164
165 let mut full_repo_view = self.generate_repository_view(repo_root)?;
167
168 if full_repo_view.commits.len() > 1 {
170 return self
171 .execute_with_map_reduce(repo_root, use_contextual, full_repo_view, claude_client)
172 .await;
173 }
174
175 let context = if use_contextual {
177 Some(self.collect_context(repo_root, &full_repo_view)?)
178 } else {
179 None
180 };
181
182 let scope_defs = match &context {
184 Some(ctx) => ctx.project.valid_scopes.clone(),
185 None => self.load_check_scopes(repo_root),
186 };
187 for commit in &mut full_repo_view.commits {
188 commit.analysis.refine_scope(&scope_defs);
189 }
190
191 if let Some(ref ctx) = context {
193 self.show_context_summary(ctx)?;
194 }
195
196 self.show_model_info_from_client(&claude_client)?;
198
199 if self.refine {
201 println!("🔄 Refine mode: using existing commit messages as starting point...");
202 }
203 if use_contextual && context.is_some() {
204 println!("🤖 Analyzing commits with enhanced contextual intelligence...");
205 } else {
206 println!("🤖 Analyzing commits with Claude AI...");
207 }
208
209 let mut amendments = if let Some(ctx) = context {
210 claude_client
211 .generate_contextual_amendments_with_options(&full_repo_view, &ctx, self.is_fresh())
212 .await?
213 } else {
214 claude_client
215 .generate_amendments_with_options(&full_repo_view, self.is_fresh())
216 .await?
217 };
218
219 refine_amendment_scopes(&mut amendments, &full_repo_view, &scope_defs);
220 {
221 use std::io::IsTerminal;
222 resolve_duplicate_amendments(
223 &mut amendments,
224 self.auto_apply,
225 std::io::stdin().is_terminal(),
226 &mut std::io::BufReader::new(std::io::stdin()),
227 )?;
228 }
229
230 if let Some(save_path) = self.save_only {
232 amendments.save_to_file(save_path)?;
233 println!("💾 Amendments saved to file");
234 return Ok(());
235 }
236
237 if !amendments.amendments.is_empty() {
239 let temp_dir = tempfile::tempdir()?;
241 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
242 amendments.save_to_file(&amendments_file)?;
243
244 {
246 use std::io::IsTerminal;
247 if !self.auto_apply
248 && !self.handle_amendments_file(
249 &amendments_file,
250 &amendments,
251 std::io::stdin().is_terminal(),
252 &mut std::io::BufReader::new(std::io::stdin()),
253 )?
254 {
255 println!("❌ Amendment cancelled by user");
256 return Ok(());
257 }
258 }
259
260 self.apply_amendments_from_file(repo_root, &amendments_file)?;
262 println!("✅ Commit messages improved successfully!");
263
264 if self.check {
266 self.run_post_twiddle_check(repo_root).await?;
267 }
268 } else {
269 println!("✨ No commits found to process!");
270 }
271
272 Ok(())
273 }
274
275 async fn execute_with_map_reduce(
282 &self,
283 repo_root: &std::path::Path,
284 use_contextual: bool,
285 mut full_repo_view: crate::data::RepositoryView,
286 claude_client: crate::claude::client::ClaudeClient,
287 ) -> Result<()> {
288 use std::sync::atomic::{AtomicUsize, Ordering};
289 use std::sync::Arc;
290
291 use crate::claude::batch;
292 use crate::claude::token_budget;
293
294 let concurrency = self.concurrency;
295
296 self.show_model_info_from_client(&claude_client)?;
298
299 if self.refine {
300 println!("🔄 Refine mode: using existing commit messages as starting point...");
301 }
302
303 let total_commits = full_repo_view.commits.len();
304 println!(
305 "🔄 Processing {total_commits} commits in parallel (concurrency: {concurrency})..."
306 );
307
308 let context = if use_contextual {
310 Some(self.collect_context(repo_root, &full_repo_view)?)
311 } else {
312 None
313 };
314
315 if let Some(ref ctx) = context {
316 self.show_context_summary(ctx)?;
317 }
318
319 let scope_defs = match &context {
321 Some(ctx) => ctx.project.valid_scopes.clone(),
322 None => self.load_check_scopes(repo_root),
323 };
324 for commit in &mut full_repo_view.commits {
325 commit.analysis.refine_scope(&scope_defs);
326 }
327
328 let metadata = claude_client.get_ai_client_metadata();
330 let system_prompt_tokens = if let Some(ref ctx) = context {
331 let prompt_style = metadata.prompt_style();
332 let system_prompt =
333 crate::claude::prompts::generate_contextual_system_prompt_for_provider(
334 ctx,
335 prompt_style,
336 );
337 token_budget::estimate_tokens(&system_prompt)
338 } else {
339 token_budget::estimate_tokens(crate::claude::prompts::SYSTEM_PROMPT)
340 };
341 let batch_plan =
342 batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
343
344 if batch_plan.batches.len() < total_commits {
345 println!(
346 " 📦 Grouped {} commits into {} batches by token budget",
347 total_commits,
348 batch_plan.batches.len()
349 );
350 }
351
352 let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
354 let completed = Arc::new(AtomicUsize::new(0));
355
356 let repo_ref = &full_repo_view;
357 let client_ref = &claude_client;
358 let context_ref = &context;
359 let fresh = self.is_fresh();
360
361 let futs: Vec<_> = batch_plan
362 .batches
363 .iter()
364 .map(|batch| {
365 let sem = semaphore.clone();
366 let completed = completed.clone();
367 let batch_indices = &batch.commit_indices;
368
369 async move {
370 let _permit = sem
371 .acquire()
372 .await
373 .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
374
375 let batch_size = batch_indices.len();
376
377 let batch_view = if batch_size == 1 {
379 repo_ref.single_commit_view(&repo_ref.commits[batch_indices[0]])
380 } else {
381 let commits: Vec<_> = batch_indices
382 .iter()
383 .map(|&i| &repo_ref.commits[i])
384 .collect();
385 repo_ref.multi_commit_view(&commits)
386 };
387
388 let result = if let Some(ref ctx) = context_ref {
390 client_ref
391 .generate_contextual_amendments_with_options(&batch_view, ctx, fresh)
392 .await
393 } else {
394 client_ref
395 .generate_amendments_with_options(&batch_view, fresh)
396 .await
397 };
398
399 match result {
400 Ok(amendment_file) => {
401 let done =
402 completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
403 println!(" ✅ {done}/{total_commits} commits processed");
404
405 let items: Vec<_> = amendment_file
406 .amendments
407 .into_iter()
408 .map(|a| {
409 let summary = a.summary.clone();
410 (a, summary)
411 })
412 .collect();
413 Ok::<_, anyhow::Error>((items, vec![]))
414 }
415 Err(e) if batch_size > 1 => {
416 eprintln!(
418 "warning: batch of {batch_size} failed, retrying individually: {e}"
419 );
420 let mut items = Vec::new();
421 let mut failed_indices = Vec::new();
422 for &idx in batch_indices {
423 let single_view =
424 repo_ref.single_commit_view(&repo_ref.commits[idx]);
425 let single_result = if let Some(ref ctx) = context_ref {
426 client_ref
427 .generate_contextual_amendments_with_options(
428 &single_view,
429 ctx,
430 fresh,
431 )
432 .await
433 } else {
434 client_ref
435 .generate_amendments_with_options(&single_view, fresh)
436 .await
437 };
438 match single_result {
439 Ok(af) => {
440 if let Some(a) = af.amendments.into_iter().next() {
441 let summary = a.summary.clone();
442 items.push((a, summary));
443 }
444 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
445 println!(" ✅ {done}/{total_commits} commits processed");
446 }
447 Err(e) => {
448 eprintln!("warning: failed to process commit: {e}");
449 for (i, cause) in e.chain().skip(1).enumerate() {
451 eprintln!(" caused by [{i}]: {cause}");
452 }
453 failed_indices.push(idx);
454 println!(" ❌ commit processing failed");
455 }
456 }
457 }
458 Ok((items, failed_indices))
459 }
460 Err(e) => {
461 let idx = batch_indices[0];
463 eprintln!("warning: failed to process commit: {e}");
464 for (i, cause) in e.chain().skip(1).enumerate() {
466 eprintln!(" caused by [{i}]: {cause}");
467 }
468 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
469 println!(" ❌ {done}/{total_commits} commits processed (failed)");
470 Ok((vec![], vec![idx]))
471 }
472 }
473 }
474 })
475 .collect();
476
477 let results = futures::future::join_all(futs).await;
478
479 let mut successes: Vec<(crate::data::amendments::Amendment, String)> = Vec::new();
481 let mut failed_indices: Vec<usize> = Vec::new();
482
483 for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
484 match result {
485 Ok((items, failed)) => {
486 successes.extend(items);
487 failed_indices.extend(failed);
488 }
489 Err(e) => {
490 eprintln!("warning: batch processing error: {e}");
491 failed_indices.extend(&batch.commit_indices);
492 }
493 }
494 }
495
496 if !failed_indices.is_empty() && !self.quiet {
498 use std::io::IsTerminal;
499 self.run_interactive_retry_generate_amendments(
500 &mut failed_indices,
501 &full_repo_view,
502 &claude_client,
503 context.as_ref(),
504 fresh,
505 &mut successes,
506 std::io::stdin().is_terminal(),
507 &mut std::io::BufReader::new(std::io::stdin()),
508 )
509 .await?;
510 } else if !failed_indices.is_empty() {
511 eprintln!(
512 "warning: {} commit(s) failed to process",
513 failed_indices.len()
514 );
515 }
516
517 if !failed_indices.is_empty() {
518 eprintln!(
519 "warning: {} commit(s) ultimately failed to process",
520 failed_indices.len()
521 );
522 }
523
524 if successes.is_empty() {
525 anyhow::bail!("All commits failed to process");
526 }
527
528 let single_batch = batch_plan.batches.len() <= 1;
531 let mut all_amendments = if !self.no_coherence && !single_batch && successes.len() >= 2 {
532 println!("🔗 Running cross-commit coherence pass...");
533 match claude_client.refine_amendments_coherence(&successes).await {
534 Ok(refined) => refined,
535 Err(e) => {
536 eprintln!("warning: coherence pass failed, using individual results: {e}");
537 AmendmentFile {
538 amendments: successes.into_iter().map(|(a, _)| a).collect(),
539 }
540 }
541 }
542 } else {
543 AmendmentFile {
544 amendments: successes.into_iter().map(|(a, _)| a).collect(),
545 }
546 };
547
548 refine_amendment_scopes(&mut all_amendments, &full_repo_view, &scope_defs);
549 {
550 use std::io::IsTerminal;
551 resolve_duplicate_amendments(
552 &mut all_amendments,
553 self.auto_apply,
554 std::io::stdin().is_terminal(),
555 &mut std::io::BufReader::new(std::io::stdin()),
556 )?;
557 }
558
559 println!(
560 "✅ All commits processed! Found {} amendments.",
561 all_amendments.amendments.len()
562 );
563
564 if let Some(save_path) = &self.save_only {
566 all_amendments.save_to_file(save_path)?;
567 println!("💾 Amendments saved to file");
568 return Ok(());
569 }
570
571 if !all_amendments.amendments.is_empty() {
573 let temp_dir = tempfile::tempdir()?;
574 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
575 all_amendments.save_to_file(&amendments_file)?;
576
577 {
578 use std::io::IsTerminal;
579 if !self.auto_apply
580 && !self.handle_amendments_file(
581 &amendments_file,
582 &all_amendments,
583 std::io::stdin().is_terminal(),
584 &mut std::io::BufReader::new(std::io::stdin()),
585 )?
586 {
587 println!("❌ Amendment cancelled by user");
588 return Ok(());
589 }
590 }
591
592 self.apply_amendments_from_file(repo_root, &amendments_file)?;
593 println!("✅ Commit messages improved successfully!");
594
595 if self.check {
596 self.run_post_twiddle_check(repo_root).await?;
597 }
598 } else {
599 println!("✨ No commits found to process!");
600 }
601
602 Ok(())
603 }
604
605 fn generate_repository_view(
607 &self,
608 repo_root: &std::path::Path,
609 ) -> Result<crate::data::RepositoryView> {
610 use crate::data::{
611 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
612 WorkingDirectoryInfo,
613 };
614 use crate::git::{GitRepository, RemoteInfo};
615 use crate::utils::ai_scratch;
616
617 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
618
619 let repo = GitRepository::open_at(repo_root)
621 .context("Failed to open git repository at the given path")?;
622
623 let current_branch = repo
625 .get_current_branch()
626 .unwrap_or_else(|_| "HEAD".to_string());
627
628 let wd_status = repo.get_working_directory_status()?;
630 let working_directory = WorkingDirectoryInfo {
631 clean: wd_status.clean,
632 untracked_changes: wd_status
633 .untracked_changes
634 .into_iter()
635 .map(|fs| FileStatusInfo {
636 status: fs.status,
637 file: fs.file,
638 })
639 .collect(),
640 };
641
642 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
644
645 let commits = repo.get_commits_in_range(commit_range)?;
647
648 let versions = Some(VersionInfo {
650 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
651 });
652
653 let ai_scratch_path = ai_scratch::get_ai_scratch_dir_at(repo_root)
655 .context("Failed to determine AI scratch directory")?;
656 let ai_info = AiInfo {
657 scratch: ai_scratch_path.to_string_lossy().to_string(),
658 };
659
660 let mut repo_view = RepositoryView {
662 versions,
663 explanation: FieldExplanation::default(),
664 working_directory,
665 remotes,
666 ai: ai_info,
667 branch_info: Some(BranchInfo {
668 branch: current_branch,
669 }),
670 pr_template: None,
671 pr_template_location: None,
672 branch_prs: None,
673 commits,
674 };
675
676 repo_view.update_field_presence();
678
679 Ok(repo_view)
680 }
681
682 fn handle_amendments_file(
687 &self,
688 amendments_file: &std::path::Path,
689 amendments: &crate::data::amendments::AmendmentFile,
690 is_terminal: bool,
691 reader: &mut (dyn std::io::BufRead + Send),
692 ) -> Result<bool> {
693 use std::io::{self, Write};
694
695 println!(
696 "\n📝 Found {} commits that could be improved.",
697 amendments.amendments.len()
698 );
699 println!("💾 Amendments saved to: {}", amendments_file.display());
700 println!();
701
702 if !is_terminal {
703 eprintln!("warning: stdin is not interactive, cannot prompt for amendments");
704 return Ok(false);
705 }
706
707 loop {
708 print!("❓ [A]pply amendments, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] ");
709 io::stdout().flush()?;
710
711 let Some(input) = super::read_interactive_line(reader)? else {
712 eprintln!("warning: stdin closed, cancelling amendments");
713 return Ok(false);
714 };
715
716 match input.trim().to_lowercase().as_str() {
717 "a" | "apply" | "" => return Ok(true),
718 "s" | "show" => {
719 self.show_amendments_file(amendments_file)?;
720 println!();
721 }
722 "e" | "edit" => {
723 self.edit_amendments_file(amendments_file)?;
724 println!();
725 }
726 "q" | "quit" => return Ok(false),
727 _ => {
728 println!(
729 "Invalid choice. Please enter 'a' to apply, 's' to show, 'e' to edit, or 'q' to quit."
730 );
731 }
732 }
733 }
734 }
735
736 fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
738 use std::fs;
739
740 println!("\n📄 Amendments file contents:");
741 println!("─────────────────────────────");
742
743 let contents =
744 fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
745
746 println!("{contents}");
747 println!("─────────────────────────────");
748
749 Ok(())
750 }
751
752 fn edit_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
754 use std::env;
755 use std::io::{self, Write};
756 use std::process::Command;
757
758 let editor = if let Ok(e) = env::var("OMNI_DEV_EDITOR").or_else(|_| env::var("EDITOR")) {
760 e
761 } else {
762 println!("🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined.");
764 print!("Please enter the command to use as your editor: ");
765 io::stdout().flush().context("Failed to flush stdout")?;
766
767 let mut input = String::new();
768 io::stdin()
769 .read_line(&mut input)
770 .context("Failed to read user input")?;
771 input.trim().to_string()
772 };
773
774 if editor.is_empty() {
775 println!("❌ No editor specified. Returning to menu.");
776 return Ok(());
777 }
778
779 println!("📝 Opening amendments file in editor: {editor}");
780
781 let (editor_cmd, args) = super::formatting::parse_editor_command(&editor);
782
783 let mut command = Command::new(editor_cmd);
784 command.args(args);
785 command.arg(amendments_file.to_string_lossy().as_ref());
786
787 match command.status() {
788 Ok(status) => {
789 if status.success() {
790 println!("✅ Editor session completed.");
791 } else {
792 println!(
793 "⚠️ Editor exited with non-zero status: {:?}",
794 status.code()
795 );
796 }
797 }
798 Err(e) => {
799 println!("❌ Failed to execute editor '{editor}': {e}");
800 println!(" Please check that the editor command is correct and available in your PATH.");
801 }
802 }
803
804 Ok(())
805 }
806
807 fn apply_amendments_from_file(
809 &self,
810 repo_root: &std::path::Path,
811 amendments_file: &std::path::Path,
812 ) -> Result<()> {
813 use crate::git::AmendmentHandler;
814
815 let handler = AmendmentHandler::new(repo_root)
818 .context("Failed to initialize amendment handler")?
819 .with_allow_pushed(self.allow_pushed);
820 handler
821 .apply_amendments(&amendments_file.to_string_lossy())
822 .context("Failed to apply amendments")?;
823
824 Ok(())
825 }
826
827 fn collect_context(
829 &self,
830 repo_root: &std::path::Path,
831 repo_view: &crate::data::RepositoryView,
832 ) -> Result<crate::data::context::CommitContext> {
833 use crate::claude::context::{
834 BranchAnalyzer, FileAnalyzer, ProjectDiscovery, WorkPatternAnalyzer,
835 };
836 use crate::data::context::CommitContext;
837
838 let mut context = CommitContext::new();
839
840 let (context_dir, dir_source) = crate::claude::context::resolve_context_dir_with_source_at(
842 self.context_dir.as_deref(),
843 repo_root,
844 );
845
846 let discovery = ProjectDiscovery::new(repo_root.to_path_buf(), context_dir.clone());
848 debug!(context_dir = ?context_dir, "Using context directory");
849 match discovery.discover() {
850 Ok(project_context) => {
851 debug!("Discovery successful");
852
853 self.show_guidance_files_status(&project_context, &context_dir, &dir_source)?;
855
856 context.project = project_context;
857 }
858 Err(e) => {
859 debug!(error = %e, "Discovery failed");
860 context.project = crate::data::context::ProjectContext::default();
861 }
862 }
863
864 if let Some(branch_info) = &repo_view.branch_info {
866 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
867 } else {
868 use crate::git::GitRepository;
870 let repo = GitRepository::open_at(repo_root)?;
871 let current_branch = repo
872 .get_current_branch()
873 .unwrap_or_else(|_| "HEAD".to_string());
874 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
875 }
876
877 if !repo_view.commits.is_empty() {
879 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
880 }
881
882 if !repo_view.commits.is_empty() {
884 context.files = FileAnalyzer::analyze_commits(&repo_view.commits);
885 }
886
887 if let Some(ref work_ctx) = self.work_context {
889 context.user_provided = Some(work_ctx.clone());
890 }
891
892 if let Some(ref branch_ctx) = self.branch_context {
893 context.branch.description.clone_from(branch_ctx);
894 }
895
896 Ok(context)
897 }
898
899 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
901 println!("🔍 Context Analysis:");
902
903 if !context.project.valid_scopes.is_empty() {
905 println!(
906 " 📁 Valid scopes: {}",
907 format_scope_list(&context.project.valid_scopes)
908 );
909 }
910
911 if context.branch.is_feature_branch {
913 println!(
914 " 🌿 Branch: {} ({})",
915 context.branch.description, context.branch.work_type
916 );
917 if let Some(ref ticket) = context.branch.ticket_id {
918 println!(" 🎫 Ticket: {ticket}");
919 }
920 }
921
922 if let Some(label) = format_work_pattern(&context.range.work_pattern) {
924 println!(" {label}");
925 }
926
927 if let Some(label) = super::formatting::format_file_analysis(&context.files) {
929 println!(" {label}");
930 }
931
932 println!(
934 " {}",
935 format_verbosity_level(context.suggested_verbosity())
936 );
937
938 if let Some(ref user_ctx) = context.user_provided {
940 println!(" 👤 User context: {user_ctx}");
941 }
942
943 println!();
944 Ok(())
945 }
946
947 fn show_model_info_from_client(
949 &self,
950 client: &crate::claude::client::ClaudeClient,
951 ) -> Result<()> {
952 use crate::claude::model_config::get_model_registry;
953
954 println!("🤖 AI Model Configuration:");
955
956 let metadata = client.get_ai_client_metadata();
958 let registry = get_model_registry();
965
966 if let Some(spec) = registry.get_model_spec(&metadata.model) {
967 if metadata.model != spec.api_identifier {
969 println!(
970 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
971 metadata.model, spec.api_identifier
972 );
973 } else {
974 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
975 }
976
977 println!(" 🏷️ Provider: {}", spec.provider);
978 println!(" 📊 Generation: {}", spec.generation);
979 println!(" ⭐ Tier: {} ({})", spec.tier, {
980 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
981 &tier_info.description
982 } else {
983 "No description available"
984 }
985 });
986 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
987 println!(" 📥 Input context: {}", metadata.max_context_length);
988
989 if let Some((ref key, ref value)) = metadata.active_beta {
990 println!(" 🔬 Beta header: {key}: {value}");
991 }
992
993 if spec.legacy {
994 println!(" ⚠️ Legacy model (consider upgrading to newer version)");
995 }
996 } else {
997 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
999 println!(" 🏷️ Provider: {}", metadata.provider);
1000 println!(" ⚠️ Model not found in registry, using client metadata:");
1001 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
1002 println!(" 📥 Input context: {}", metadata.max_context_length);
1003 }
1004
1005 println!();
1006 Ok(())
1007 }
1008
1009 fn show_guidance_files_status(
1011 &self,
1012 project_context: &crate::data::context::ProjectContext,
1013 context_dir: &std::path::Path,
1014 dir_source: &crate::claude::context::ConfigDirSource,
1015 ) -> Result<()> {
1016 use crate::claude::context::{config_source_label, ConfigSourceLabel};
1017
1018 println!("📋 Project guidance files status:");
1019 println!(" 📂 Config dir: {} ({dir_source})", context_dir.display());
1020
1021 let guidelines_source = if project_context.commit_guidelines.is_some() {
1023 match config_source_label(context_dir, "commit-guidelines.md") {
1024 ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
1025 label => format!("✅ {label}"),
1026 }
1027 } else {
1028 "❌ None found".to_string()
1029 };
1030 println!(" 📝 Commit guidelines: {guidelines_source}");
1031
1032 let scopes_count = project_context.valid_scopes.len();
1034 let scopes_source = if scopes_count > 0 {
1035 match config_source_label(context_dir, "scopes.yaml") {
1036 ConfigSourceLabel::NotFound => {
1037 format!("✅ (source unknown + ecosystem defaults) ({scopes_count} scopes)")
1038 }
1039 label => format!("✅ {label} ({scopes_count} scopes)"),
1040 }
1041 } else {
1042 "❌ None found".to_string()
1043 };
1044 println!(" 🎯 Valid scopes: {scopes_source}");
1045
1046 println!();
1047 Ok(())
1048 }
1049
1050 async fn execute_no_ai(&self, repo_root: &std::path::Path) -> Result<()> {
1056 use crate::data::amendments::AmendmentFile;
1057
1058 println!("📋 Generating deterministic amendments without AI processing...");
1059
1060 let repo_view = self.generate_repository_view(repo_root)?;
1062 let scope_defs = self.load_check_scopes(repo_root);
1063
1064 let amendment_file = AmendmentFile {
1065 amendments: deterministic_amendments(&repo_view, &scope_defs),
1066 };
1067
1068 if let Some(save_path) = &self.save_only {
1071 amendment_file.save_to_file(save_path)?;
1072 println!("💾 Amendments saved to file");
1073 return Ok(());
1074 }
1075
1076 if repo_view.commits.is_empty() {
1077 println!("✨ No commits found to process!");
1078 return Ok(());
1079 }
1080 if amendment_file.amendments.is_empty() {
1081 println!("✨ No changes needed: every commit message already conforms.");
1082 return Ok(());
1083 }
1084
1085 let temp_dir = tempfile::tempdir()?;
1088 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
1089 amendment_file.save_to_file(&amendments_file)?;
1090
1091 {
1093 use std::io::IsTerminal;
1094 if !self.auto_apply
1095 && !self.handle_amendments_file(
1096 &amendments_file,
1097 &amendment_file,
1098 std::io::stdin().is_terminal(),
1099 &mut std::io::BufReader::new(std::io::stdin()),
1100 )?
1101 {
1102 println!("❌ Amendment cancelled by user");
1103 return Ok(());
1104 }
1105 }
1106
1107 self.apply_amendments_from_file(repo_root, &amendments_file)?;
1109 println!("✅ Commit messages applied successfully!");
1110
1111 if self.check {
1113 self.run_post_twiddle_check(repo_root).await?;
1114 }
1115
1116 Ok(())
1117 }
1118
1119 async fn run_post_twiddle_check(&self, repo_root: &std::path::Path) -> Result<()> {
1123 const MAX_CHECK_RETRIES: u32 = 3;
1124
1125 let guidelines = self.load_check_guidelines(repo_root)?;
1127 let valid_scopes = self.load_check_scopes(repo_root);
1128 let claude_client = crate::claude::create_default_claude_client(None, None).await?;
1129
1130 for attempt in 0..=MAX_CHECK_RETRIES {
1131 println!();
1132 if attempt == 0 {
1133 println!("🔍 Running commit message validation...");
1134 } else {
1135 println!("🔍 Re-checking commit messages (retry {attempt}/{MAX_CHECK_RETRIES})...");
1136 }
1137
1138 let mut repo_view = self.generate_repository_view(repo_root)?;
1140
1141 if repo_view.commits.is_empty() {
1142 println!("⚠️ No commits to check");
1143 return Ok(());
1144 }
1145
1146 println!("📊 Checking {} commits", repo_view.commits.len());
1147
1148 for commit in &mut repo_view.commits {
1150 commit.analysis.refine_scope(&valid_scopes);
1151 }
1152
1153 if attempt == 0 {
1154 self.show_check_guidance_files_status(repo_root, &guidelines, &valid_scopes);
1155 }
1156
1157 let report = if repo_view.commits.len() > 1 {
1159 println!(
1160 "🔄 Checking {} commits in parallel...",
1161 repo_view.commits.len()
1162 );
1163 self.check_commits_map_reduce(
1164 &claude_client,
1165 &repo_view,
1166 guidelines.as_deref(),
1167 &valid_scopes,
1168 )
1169 .await?
1170 } else {
1171 println!("🤖 Analyzing commits with AI...");
1172 claude_client
1173 .check_commits_with_scopes(
1174 &repo_view,
1175 guidelines.as_deref(),
1176 &valid_scopes,
1177 true,
1178 )
1179 .await?
1180 };
1181
1182 self.output_check_text_report(&report)?;
1184
1185 if !report.has_errors() {
1187 if report.has_warnings() {
1188 println!("ℹ️ Some commit messages have minor warnings");
1189 } else {
1190 println!("✅ All commit messages pass validation");
1191 }
1192 return Ok(());
1193 }
1194
1195 if attempt == MAX_CHECK_RETRIES {
1197 println!(
1198 "⚠️ Some commit messages still have issues after {MAX_CHECK_RETRIES} retries"
1199 );
1200 return Ok(());
1201 }
1202
1203 let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
1205
1206 if amendments.is_empty() {
1207 println!(
1208 "⚠️ Some commit messages have issues but no suggestions available to retry"
1209 );
1210 return Ok(());
1211 }
1212
1213 println!(
1215 "🔄 Applying {} suggested fix(es) and re-checking...",
1216 amendments.len()
1217 );
1218 let amendment_file = AmendmentFile { amendments };
1219 let temp_file = tempfile::NamedTempFile::new()
1220 .context("Failed to create temp file for retry amendments")?;
1221 amendment_file
1222 .save_to_file(temp_file.path())
1223 .context("Failed to save retry amendments")?;
1224 self.apply_amendments_from_file(repo_root, temp_file.path())?;
1225 }
1226
1227 Ok(())
1228 }
1229
1230 fn build_amendments_from_suggestions(
1234 &self,
1235 report: &crate::data::check::CheckReport,
1236 repo_view: &crate::data::RepositoryView,
1237 ) -> Vec<crate::data::amendments::Amendment> {
1238 use crate::data::amendments::Amendment;
1239
1240 let candidate_hashes: Vec<String> =
1241 repo_view.commits.iter().map(|c| c.hash.clone()).collect();
1242
1243 report
1244 .commits
1245 .iter()
1246 .filter(|r| !r.passes)
1247 .filter_map(|r| {
1248 let suggestion = r.suggestion.as_ref()?;
1249 let full_hash = super::formatting::resolve_short_hash(&r.hash, &candidate_hashes)?;
1250 Some(Amendment::new(
1251 full_hash.to_string(),
1252 suggestion.message.clone(),
1253 ))
1254 })
1255 .collect()
1256 }
1257
1258 fn load_check_guidelines(&self, repo_root: &std::path::Path) -> Result<Option<String>> {
1260 let context_dir =
1261 crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
1262 crate::claude::context::load_config_content(&context_dir, "commit-guidelines.md")
1263 }
1264
1265 fn load_check_scopes(
1267 &self,
1268 repo_root: &std::path::Path,
1269 ) -> Vec<crate::data::context::ScopeDefinition> {
1270 let context_dir =
1271 crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
1272 crate::claude::context::load_project_scopes(&context_dir, repo_root)
1273 }
1274
1275 fn show_check_guidance_files_status(
1277 &self,
1278 repo_root: &std::path::Path,
1279 guidelines: &Option<String>,
1280 valid_scopes: &[crate::data::context::ScopeDefinition],
1281 ) {
1282 use crate::claude::context::{
1283 config_source_label, resolve_context_dir_with_source_at, ConfigSourceLabel,
1284 };
1285
1286 let (context_dir, dir_source) =
1287 resolve_context_dir_with_source_at(self.context_dir.as_deref(), repo_root);
1288
1289 println!("📋 Project guidance files status:");
1290 println!(" 📂 Config dir: {} ({dir_source})", context_dir.display());
1291
1292 let guidelines_source = if guidelines.is_some() {
1294 match config_source_label(&context_dir, "commit-guidelines.md") {
1295 ConfigSourceLabel::NotFound => "✅ (source unknown)".to_string(),
1296 label => format!("✅ {label}"),
1297 }
1298 } else {
1299 "⚪ Using defaults".to_string()
1300 };
1301 println!(" 📝 Commit guidelines: {guidelines_source}");
1302
1303 let scopes_count = valid_scopes.len();
1305 let scopes_source = if scopes_count > 0 {
1306 match config_source_label(&context_dir, "scopes.yaml") {
1307 ConfigSourceLabel::NotFound => {
1308 format!("✅ (source unknown) ({scopes_count} scopes)")
1309 }
1310 label => format!("✅ {label} ({scopes_count} scopes)"),
1311 }
1312 } else {
1313 "⚪ None found (any scope accepted)".to_string()
1314 };
1315 println!(" 🎯 Valid scopes: {scopes_source}");
1316
1317 println!();
1318 }
1319
1320 async fn check_commits_map_reduce(
1322 &self,
1323 claude_client: &crate::claude::client::ClaudeClient,
1324 full_repo_view: &crate::data::RepositoryView,
1325 guidelines: Option<&str>,
1326 valid_scopes: &[crate::data::context::ScopeDefinition],
1327 ) -> Result<crate::data::check::CheckReport> {
1328 use std::sync::atomic::{AtomicUsize, Ordering};
1329 use std::sync::Arc;
1330
1331 use crate::claude::batch;
1332 use crate::claude::token_budget;
1333 use crate::data::check::{CheckReport, CommitCheckResult};
1334
1335 let total_commits = full_repo_view.commits.len();
1336
1337 let metadata = claude_client.get_ai_client_metadata();
1339 let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
1340 guidelines,
1341 valid_scopes,
1342 );
1343 let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
1344 let batch_plan =
1345 batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
1346
1347 if batch_plan.batches.len() < total_commits {
1348 println!(
1349 " 📦 Grouped {} commits into {} batches by token budget",
1350 total_commits,
1351 batch_plan.batches.len()
1352 );
1353 }
1354
1355 let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
1356 let completed = Arc::new(AtomicUsize::new(0));
1357
1358 let futs: Vec<_> = batch_plan
1359 .batches
1360 .iter()
1361 .map(|batch| {
1362 let sem = semaphore.clone();
1363 let completed = completed.clone();
1364 let batch_indices = &batch.commit_indices;
1365
1366 async move {
1367 let _permit = sem
1368 .acquire()
1369 .await
1370 .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
1371
1372 let batch_size = batch_indices.len();
1373
1374 let batch_view = if batch_size == 1 {
1375 full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
1376 } else {
1377 let commits: Vec<_> = batch_indices
1378 .iter()
1379 .map(|&i| &full_repo_view.commits[i])
1380 .collect();
1381 full_repo_view.multi_commit_view(&commits)
1382 };
1383
1384 let result = claude_client
1385 .check_commits_with_scopes(&batch_view, guidelines, valid_scopes, true)
1386 .await;
1387
1388 match result {
1389 Ok(report) => {
1390 let done =
1391 completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
1392 println!(" ✅ {done}/{total_commits} commits checked");
1393
1394 let items: Vec<_> = report
1395 .commits
1396 .into_iter()
1397 .map(|r| {
1398 let summary = r.summary.clone().unwrap_or_default();
1399 (r, summary)
1400 })
1401 .collect();
1402 Ok::<_, anyhow::Error>((items, vec![]))
1403 }
1404 Err(e) if batch_size > 1 => {
1405 eprintln!(
1406 "warning: batch of {batch_size} failed, retrying individually: {e}"
1407 );
1408 let mut items = Vec::new();
1409 let mut failed_indices = Vec::new();
1410 for &idx in batch_indices {
1411 let single_view =
1412 full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1413 let single_result = claude_client
1414 .check_commits_with_scopes(
1415 &single_view,
1416 guidelines,
1417 valid_scopes,
1418 true,
1419 )
1420 .await;
1421 match single_result {
1422 Ok(report) => {
1423 if let Some(r) = report.commits.into_iter().next() {
1424 let summary = r.summary.clone().unwrap_or_default();
1425 items.push((r, summary));
1426 }
1427 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
1428 println!(" ✅ {done}/{total_commits} commits checked");
1429 }
1430 Err(e) => {
1431 eprintln!("warning: failed to check commit: {e}");
1432 failed_indices.push(idx);
1433 println!(" ❌ commit check failed");
1434 }
1435 }
1436 }
1437 Ok((items, failed_indices))
1438 }
1439 Err(e) => {
1440 let idx = batch_indices[0];
1442 eprintln!("warning: failed to check commit: {e}");
1443 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
1444 println!(" ❌ {done}/{total_commits} commits checked (failed)");
1445 Ok((vec![], vec![idx]))
1446 }
1447 }
1448 }
1449 })
1450 .collect();
1451
1452 let results = futures::future::join_all(futs).await;
1453
1454 let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
1455 let mut failed_indices: Vec<usize> = Vec::new();
1456
1457 for (result, batch) in results.into_iter().zip(&batch_plan.batches) {
1458 match result {
1459 Ok((items, failed)) => {
1460 successes.extend(items);
1461 failed_indices.extend(failed);
1462 }
1463 Err(e) => {
1464 eprintln!("warning: batch processing error: {e}");
1465 failed_indices.extend(&batch.commit_indices);
1466 }
1467 }
1468 }
1469
1470 if !failed_indices.is_empty() && !self.quiet {
1472 use std::io::IsTerminal;
1473 if std::io::stdin().is_terminal() {
1474 self.run_interactive_retry_twiddle_check(
1475 &mut failed_indices,
1476 full_repo_view,
1477 claude_client,
1478 guidelines,
1479 valid_scopes,
1480 &mut successes,
1481 &mut std::io::BufReader::new(std::io::stdin()),
1482 )
1483 .await?;
1484 } else {
1485 eprintln!(
1486 "warning: stdin is not interactive, skipping retry prompt for {} failed commit(s)",
1487 failed_indices.len()
1488 );
1489 }
1490 } else if !failed_indices.is_empty() {
1491 eprintln!(
1492 "warning: {} commit(s) failed to check",
1493 failed_indices.len()
1494 );
1495 }
1496
1497 if !failed_indices.is_empty() {
1498 eprintln!(
1499 "warning: {} commit(s) ultimately failed to check",
1500 failed_indices.len()
1501 );
1502 }
1503
1504 if successes.is_empty() {
1505 anyhow::bail!("All commits failed to check");
1506 }
1507
1508 let single_batch = batch_plan.batches.len() <= 1;
1510 if !self.no_coherence && !single_batch && successes.len() >= 2 {
1511 println!("🔗 Running cross-commit coherence pass...");
1512 match claude_client
1513 .refine_checks_coherence(&successes, full_repo_view)
1514 .await
1515 {
1516 Ok(refined) => return Ok(refined),
1517 Err(e) => {
1518 eprintln!("warning: coherence pass failed, using individual results: {e}");
1519 }
1520 }
1521 }
1522
1523 Ok(CheckReport::new(
1524 successes.into_iter().map(|(r, _)| r).collect(),
1525 ))
1526 }
1527
1528 #[allow(clippy::too_many_arguments)]
1532 async fn run_interactive_retry_twiddle_check(
1533 &self,
1534 failed_indices: &mut Vec<usize>,
1535 full_repo_view: &crate::data::RepositoryView,
1536 claude_client: &crate::claude::client::ClaudeClient,
1537 guidelines: Option<&str>,
1538 valid_scopes: &[crate::data::context::ScopeDefinition],
1539 successes: &mut Vec<(crate::data::check::CommitCheckResult, String)>,
1540 reader: &mut (dyn std::io::BufRead + Send),
1541 ) -> Result<()> {
1542 use std::io::Write as _;
1543 println!("\n⚠️ {} commit(s) failed to check:", failed_indices.len());
1544 for &idx in failed_indices.iter() {
1545 let commit = &full_repo_view.commits[idx];
1546 let subject = commit
1547 .original_message
1548 .lines()
1549 .next()
1550 .unwrap_or("(no message)");
1551 println!(" - {}: {}", &commit.hash[..8], subject);
1552 }
1553 loop {
1554 print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
1555 std::io::stdout().flush()?;
1556 let Some(input) = super::read_interactive_line(reader)? else {
1557 eprintln!("warning: stdin closed, skipping failed commit(s)");
1558 break;
1559 };
1560 match input.trim().to_lowercase().as_str() {
1561 "r" | "retry" | "" => {
1562 let mut still_failed = Vec::new();
1563 for &idx in failed_indices.iter() {
1564 let single_view =
1565 full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1566 match claude_client
1567 .check_commits_with_scopes(&single_view, guidelines, valid_scopes, true)
1568 .await
1569 {
1570 Ok(report) => {
1571 if let Some(r) = report.commits.into_iter().next() {
1572 let summary = r.summary.clone().unwrap_or_default();
1573 successes.push((r, summary));
1574 }
1575 }
1576 Err(e) => {
1577 eprintln!("warning: still failed: {e}");
1578 still_failed.push(idx);
1579 }
1580 }
1581 }
1582 *failed_indices = still_failed;
1583 if failed_indices.is_empty() {
1584 println!("✅ All retried commits succeeded.");
1585 break;
1586 }
1587 println!("\n⚠️ {} commit(s) still failed:", failed_indices.len());
1588 for &idx in failed_indices.iter() {
1589 let commit = &full_repo_view.commits[idx];
1590 let subject = commit
1591 .original_message
1592 .lines()
1593 .next()
1594 .unwrap_or("(no message)");
1595 println!(" - {}: {}", &commit.hash[..8], subject);
1596 }
1597 }
1598 "s" | "skip" => {
1599 println!("Skipping {} failed commit(s).", failed_indices.len());
1600 break;
1601 }
1602 _ => println!("Please enter 'r' to retry or 's' to skip."),
1603 }
1604 }
1605 Ok(())
1606 }
1607
1608 #[allow(clippy::too_many_arguments)]
1614 async fn run_interactive_retry_generate_amendments(
1615 &self,
1616 failed_indices: &mut Vec<usize>,
1617 full_repo_view: &crate::data::RepositoryView,
1618 claude_client: &crate::claude::client::ClaudeClient,
1619 context: Option<&crate::data::context::CommitContext>,
1620 fresh: bool,
1621 successes: &mut Vec<(crate::data::amendments::Amendment, String)>,
1622 is_terminal: bool,
1623 reader: &mut (dyn std::io::BufRead + Send),
1624 ) -> Result<()> {
1625 use std::io::Write as _;
1626 println!(
1627 "\n⚠️ {} commit(s) failed to process:",
1628 failed_indices.len()
1629 );
1630 for &idx in failed_indices.iter() {
1631 let commit = &full_repo_view.commits[idx];
1632 let subject = commit
1633 .original_message
1634 .lines()
1635 .next()
1636 .unwrap_or("(no message)");
1637 println!(" - {}: {}", &commit.hash[..8], subject);
1638 }
1639 if !is_terminal {
1640 eprintln!(
1641 "warning: stdin is not interactive, skipping retry prompt for {} failed commit(s)",
1642 failed_indices.len()
1643 );
1644 return Ok(());
1645 }
1646 loop {
1647 print!("\n❓ [R]etry failed commits, or [S]kip? [R/s] ");
1648 std::io::stdout().flush()?;
1649 let Some(input) = super::read_interactive_line(reader)? else {
1650 eprintln!("warning: stdin closed, skipping failed commit(s)");
1651 break;
1652 };
1653 match input.trim().to_lowercase().as_str() {
1654 "r" | "retry" | "" => {
1655 let mut still_failed = Vec::new();
1656 for &idx in failed_indices.iter() {
1657 let single_view =
1658 full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1659 let result = if let Some(ctx) = context {
1660 claude_client
1661 .generate_contextual_amendments_with_options(
1662 &single_view,
1663 ctx,
1664 fresh,
1665 )
1666 .await
1667 } else {
1668 claude_client
1669 .generate_amendments_with_options(&single_view, fresh)
1670 .await
1671 };
1672 match result {
1673 Ok(af) => {
1674 if let Some(a) = af.amendments.into_iter().next() {
1675 let summary = a.summary.clone();
1676 successes.push((a, summary));
1677 }
1678 }
1679 Err(e) => {
1680 eprintln!("warning: still failed: {e}");
1681 still_failed.push(idx);
1682 }
1683 }
1684 }
1685 *failed_indices = still_failed;
1686 if failed_indices.is_empty() {
1687 println!("✅ All retried commits succeeded.");
1688 break;
1689 }
1690 println!("\n⚠️ {} commit(s) still failed:", failed_indices.len());
1691 for &idx in failed_indices.iter() {
1692 let commit = &full_repo_view.commits[idx];
1693 let subject = commit
1694 .original_message
1695 .lines()
1696 .next()
1697 .unwrap_or("(no message)");
1698 println!(" - {}: {}", &commit.hash[..8], subject);
1699 }
1700 }
1701 "s" | "skip" => {
1702 println!("Skipping {} failed commit(s).", failed_indices.len());
1703 break;
1704 }
1705 _ => println!("Please enter 'r' to retry or 's' to skip."),
1706 }
1707 }
1708 Ok(())
1709 }
1710
1711 fn output_check_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
1713 println!();
1714
1715 for result in &report.commits {
1716 if result.passes {
1718 continue;
1719 }
1720
1721 let icon = super::formatting::determine_commit_icon(result.passes, &result.issues);
1722 let short_hash = super::formatting::truncate_hash(&result.hash);
1723
1724 println!("{} {} - \"{}\"", icon, short_hash, result.message);
1725
1726 for issue in &result.issues {
1728 let severity_str = super::formatting::format_severity_label(issue.severity);
1729
1730 println!(
1731 " {} [{}] {}",
1732 severity_str, issue.section, issue.explanation
1733 );
1734 }
1735
1736 if let Some(suggestion) = &result.suggestion {
1738 println!();
1739 println!(" Suggested message:");
1740 for line in suggestion.message.lines() {
1741 println!(" {line}");
1742 }
1743 }
1744
1745 println!();
1746 }
1747
1748 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1750 println!("Summary: {} commits checked", report.summary.total_commits);
1751 println!(
1752 " {} errors, {} warnings",
1753 report.summary.error_count, report.summary.warning_count
1754 );
1755 println!(
1756 " {} passed, {} with issues",
1757 report.summary.passing_commits, report.summary.failing_commits
1758 );
1759
1760 Ok(())
1761 }
1762}
1763
1764#[derive(Debug, Clone)]
1766pub struct TwiddleOutcome {
1767 pub amendments_yaml: String,
1769 pub applied: bool,
1772 pub amendment_count: usize,
1774}
1775
1776pub async fn run_twiddle(
1790 range: Option<&str>,
1791 model: Option<String>,
1792 dry_run: bool,
1793 repo_path: Option<&std::path::Path>,
1794) -> Result<TwiddleOutcome> {
1795 let repo_root = match repo_path {
1796 Some(p) => p.to_path_buf(),
1797 None => std::env::current_dir().context("Failed to determine current directory")?,
1798 };
1799 let repo_root = repo_root.as_path();
1800
1801 crate::utils::check_ai_command_prerequisites(model.as_deref(), repo_root)?;
1802
1803 if !dry_run {
1804 crate::utils::check_working_directory_clean_at(repo_root)?;
1805 }
1806
1807 let claude_client = crate::claude::create_default_claude_client(model, None).await?;
1808 run_twiddle_with_client(range, dry_run, repo_root, &claude_client).await
1809}
1810
1811pub(crate) async fn run_twiddle_with_client(
1818 range: Option<&str>,
1819 dry_run: bool,
1820 repo_root: &std::path::Path,
1821 claude_client: &crate::claude::client::ClaudeClient,
1822) -> Result<TwiddleOutcome> {
1823 use crate::data::{
1824 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
1825 WorkingDirectoryInfo,
1826 };
1827 use crate::git::{GitRepository, RemoteInfo};
1828 use crate::utils::ai_scratch;
1829
1830 let resolved_range = range.unwrap_or("HEAD~5..HEAD");
1831
1832 let repo = GitRepository::open_at(repo_root)
1833 .context("Failed to open git repository at the given path")?;
1834
1835 let current_branch = repo
1836 .get_current_branch()
1837 .unwrap_or_else(|_| "HEAD".to_string());
1838
1839 let wd_status = repo.get_working_directory_status()?;
1840 let working_directory = WorkingDirectoryInfo {
1841 clean: wd_status.clean,
1842 untracked_changes: wd_status
1843 .untracked_changes
1844 .into_iter()
1845 .map(|fs| FileStatusInfo {
1846 status: fs.status,
1847 file: fs.file,
1848 })
1849 .collect(),
1850 };
1851
1852 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1853 let commits = repo.get_commits_in_range(resolved_range)?;
1854
1855 if commits.is_empty() {
1856 let empty_file = AmendmentFile { amendments: vec![] };
1857 let yaml =
1858 crate::data::to_yaml(&empty_file).context("Failed to serialise empty AmendmentFile")?;
1859 return Ok(TwiddleOutcome {
1860 amendments_yaml: yaml,
1861 applied: false,
1862 amendment_count: 0,
1863 });
1864 }
1865
1866 let ai_scratch_path = ai_scratch::get_ai_scratch_dir_at(repo_root)
1867 .context("Failed to determine AI scratch directory")?;
1868 let ai_info = AiInfo {
1869 scratch: ai_scratch_path.to_string_lossy().to_string(),
1870 };
1871
1872 let mut repo_view = RepositoryView {
1873 versions: Some(VersionInfo {
1874 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1875 }),
1876 explanation: FieldExplanation::default(),
1877 working_directory,
1878 remotes,
1879 ai: ai_info,
1880 branch_info: Some(BranchInfo {
1881 branch: current_branch,
1882 }),
1883 pr_template: None,
1884 pr_template_location: None,
1885 branch_prs: None,
1886 commits,
1887 };
1888 repo_view.update_field_presence();
1889
1890 let mut amendments = claude_client
1891 .generate_amendments_with_options(&repo_view, true)
1892 .await?;
1893
1894 let context_dir = crate::claude::context::resolve_context_dir_at(None, repo_root);
1895 let scope_defs = crate::claude::context::load_project_scopes(&context_dir, repo_root);
1896 refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
1897
1898 let amendments_yaml =
1899 crate::data::to_yaml(&amendments).context("Failed to serialise AmendmentFile")?;
1900 let amendment_count = amendments.amendments.len();
1901
1902 if dry_run || amendment_count == 0 {
1903 return Ok(TwiddleOutcome {
1904 amendments_yaml,
1905 applied: false,
1906 amendment_count,
1907 });
1908 }
1909
1910 let temp_dir = tempfile::tempdir().context("Failed to create temp dir")?;
1911 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
1912 amendments
1913 .save_to_file(&amendments_file)
1914 .context("Failed to save amendments")?;
1915 let handler = crate::git::AmendmentHandler::new(repo_root)
1916 .context("Failed to initialise amendment handler")?;
1917 handler
1918 .apply_amendments(&amendments_file.to_string_lossy())
1919 .context("Failed to apply amendments")?;
1920
1921 Ok(TwiddleOutcome {
1922 amendments_yaml,
1923 applied: true,
1924 amendment_count,
1925 })
1926}
1927
1928#[cfg(test)]
1929#[allow(clippy::unwrap_used, clippy::expect_used)]
1930mod run_twiddle_tests {
1931 use super::*;
1932 use crate::claude::client::ClaudeClient;
1933 use crate::claude::test_utils::ConfigurableMockAiClient;
1934 use git2::{Repository, Signature};
1935
1936 #[tokio::test]
1941 async fn run_twiddle_invalid_repo_path_errors_before_ai() {
1942 let err = run_twiddle(
1943 None,
1944 None,
1945 true,
1946 Some(std::path::Path::new("/no/such/path/exists")),
1947 )
1948 .await
1949 .unwrap_err();
1950 let msg = format!("{err:#}");
1951 assert!(
1952 msg.to_lowercase().contains("git") || msg.to_lowercase().contains("repository"),
1953 "expected git/repository error, got: {msg}"
1954 );
1955 }
1956
1957 fn init_test_repo_with_commit() -> (tempfile::TempDir, String) {
1958 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
1959 std::fs::create_dir_all(&tmp_root).unwrap();
1960 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
1961 let repo = Repository::init(temp_dir.path()).unwrap();
1962 {
1963 let mut cfg = repo.config().unwrap();
1964 cfg.set_str("user.name", "Test").unwrap();
1965 cfg.set_str("user.email", "test@example.com").unwrap();
1966 cfg.set_bool("commit.gpgsign", false).unwrap();
1971 }
1972 let signature = Signature::now("Test", "test@example.com").unwrap();
1973 std::fs::write(temp_dir.path().join("f.txt"), "c").unwrap();
1974 let mut idx = repo.index().unwrap();
1975 idx.add_path(std::path::Path::new("f.txt")).unwrap();
1976 idx.write().unwrap();
1977 let tree_id = idx.write_tree().unwrap();
1978 let tree = repo.find_tree(tree_id).unwrap();
1979 let oid = repo
1980 .commit(
1981 Some("HEAD"),
1982 &signature,
1983 &signature,
1984 "feat: original",
1985 &tree,
1986 &[],
1987 )
1988 .unwrap();
1989 (temp_dir, oid.to_string())
1990 }
1991
1992 fn amendment_yaml(hash: &str, msg: &str) -> String {
1993 format!("amendments:\n - commit: {hash}\n message: '{msg}'\n")
1994 }
1995
1996 #[tokio::test]
1997 async fn run_twiddle_with_client_dry_run_returns_amendments() {
1998 let (temp_dir, hash) = init_test_repo_with_commit();
1999
2000 let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
2001 &hash,
2002 "feat(cli): better subject",
2003 ))]);
2004 let client = ClaudeClient::new(Box::new(mock));
2005
2006 let outcome = run_twiddle_with_client(Some("HEAD"), true, temp_dir.path(), &client)
2007 .await
2008 .unwrap();
2009 assert!(!outcome.applied, "dry_run must not apply");
2010 assert_eq!(outcome.amendment_count, 1);
2011 assert!(outcome.amendments_yaml.contains("amendments:"));
2012 }
2013
2014 #[tokio::test]
2015 async fn run_twiddle_with_client_empty_range_returns_empty() {
2016 let (temp_dir, _hash) = init_test_repo_with_commit();
2017
2018 let mock = ConfigurableMockAiClient::new(vec![]);
2019 let client = ClaudeClient::new(Box::new(mock));
2020
2021 let outcome = run_twiddle_with_client(Some("HEAD..HEAD"), true, temp_dir.path(), &client)
2022 .await
2023 .unwrap();
2024 assert_eq!(outcome.amendment_count, 0);
2025 assert!(!outcome.applied);
2026 }
2027
2028 #[tokio::test]
2029 async fn run_twiddle_with_client_ai_failure_errors() {
2030 let (temp_dir, _hash) = init_test_repo_with_commit();
2031
2032 let mock = ConfigurableMockAiClient::new(vec![]);
2033 let client = ClaudeClient::new(Box::new(mock));
2034 let err = run_twiddle_with_client(Some("HEAD"), true, temp_dir.path(), &client)
2035 .await
2036 .unwrap_err();
2037 let _ = err;
2038 }
2039
2040 #[tokio::test]
2041 async fn run_twiddle_with_client_default_range_errors_on_sparse_repo() {
2042 let (temp_dir, _hash) = init_test_repo_with_commit();
2043
2044 let mock = ConfigurableMockAiClient::new(vec![]);
2048 let client = ClaudeClient::new(Box::new(mock));
2049
2050 let err = run_twiddle_with_client(None, true, temp_dir.path(), &client)
2051 .await
2052 .unwrap_err();
2053 assert!(
2054 format!("{err:#}").contains("HEAD~5")
2055 || format!("{err:#}").to_lowercase().contains("not found"),
2056 "expected HEAD~5 resolution error"
2057 );
2058 }
2059
2060 #[test]
2061 fn twiddle_outcome_clone_and_debug() {
2062 let outcome = TwiddleOutcome {
2063 amendments_yaml: "x".to_string(),
2064 applied: true,
2065 amendment_count: 2,
2066 };
2067 let cloned = outcome.clone();
2068 assert_eq!(format!("{outcome:?}"), format!("{cloned:?}"));
2069 }
2070
2071 #[tokio::test]
2075 async fn run_twiddle_with_client_applies_head_amendment() {
2076 let (temp_dir, hash) = init_test_repo_with_commit();
2077
2078 let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
2079 &hash,
2080 "feat(cli): much better subject",
2081 ))]);
2082 let client = ClaudeClient::new(Box::new(mock));
2083
2084 let outcome = run_twiddle_with_client(Some("HEAD"), false, temp_dir.path(), &client)
2085 .await
2086 .unwrap();
2087 assert!(outcome.applied, "dry_run=false must apply amendments");
2088 assert_eq!(outcome.amendment_count, 1);
2089
2090 let repo = git2::Repository::open(temp_dir.path()).unwrap();
2092 let head_msg = repo
2093 .head()
2094 .unwrap()
2095 .peel_to_commit()
2096 .unwrap()
2097 .message()
2098 .unwrap()
2099 .to_string();
2100 assert!(
2101 head_msg.contains("much better subject"),
2102 "HEAD message should be rewritten: {head_msg}"
2103 );
2104 }
2105
2106 #[tokio::test]
2112 async fn run_twiddle_with_client_targets_injected_repo_not_cwd() {
2113 let (temp_dir, hash) = init_test_repo_with_commit();
2114
2115 let cwd = std::env::current_dir().unwrap();
2118 assert_ne!(
2119 cwd.canonicalize().unwrap(),
2120 temp_dir.path().canonicalize().unwrap(),
2121 "test precondition: process CWD must differ from the injected repo"
2122 );
2123
2124 let mock = ConfigurableMockAiClient::new(vec![Ok(amendment_yaml(
2125 &hash,
2126 "feat(cli): injected-repo subject",
2127 ))]);
2128 let client = ClaudeClient::new(Box::new(mock));
2129
2130 let outcome = run_twiddle_with_client(Some("HEAD"), false, temp_dir.path(), &client)
2131 .await
2132 .unwrap();
2133 assert!(outcome.applied, "dry_run=false must apply amendments");
2134 assert_eq!(outcome.amendment_count, 1);
2135
2136 let repo = git2::Repository::open(temp_dir.path()).unwrap();
2138 let head_msg = repo
2139 .head()
2140 .unwrap()
2141 .peel_to_commit()
2142 .unwrap()
2143 .message()
2144 .unwrap()
2145 .to_string();
2146 assert!(
2147 head_msg.contains("injected-repo subject"),
2148 "injected repo HEAD must be rewritten: {head_msg}"
2149 );
2150 }
2151}
2152
2153#[cfg(test)]
2154#[allow(clippy::unwrap_used, clippy::expect_used)]
2155mod execute_tests {
2156 use super::*;
2157 use crate::claude::client::ClaudeClient;
2158 use crate::claude::test_utils::ConfigurableMockAiClient;
2159 use git2::{Repository, Signature};
2160
2161 fn init_test_repo_with_n_commits(n: usize) -> (tempfile::TempDir, Vec<String>) {
2165 assert!(n >= 1);
2166 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
2167 std::fs::create_dir_all(&tmp_root).unwrap();
2168 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
2169 let repo = Repository::init(temp_dir.path()).unwrap();
2170 {
2171 let mut cfg = repo.config().unwrap();
2172 cfg.set_str("user.name", "Test").unwrap();
2173 cfg.set_str("user.email", "test@example.com").unwrap();
2174 cfg.set_bool("commit.gpgsign", false).unwrap();
2179 }
2180 let signature = Signature::now("Test", "test@example.com").unwrap();
2181
2182 let mut hashes = Vec::with_capacity(n);
2183 let mut parent_oid: Option<git2::Oid> = None;
2184
2185 for i in 0..n {
2186 let file = format!("f{i}.txt");
2187 std::fs::write(temp_dir.path().join(&file), format!("contents {i}")).unwrap();
2188 let mut idx = repo.index().unwrap();
2189 idx.add_path(std::path::Path::new(&file)).unwrap();
2190 idx.write().unwrap();
2191 let tree_id = idx.write_tree().unwrap();
2192 let tree = repo.find_tree(tree_id).unwrap();
2193 let msg = format!("feat: original commit {i}");
2194
2195 let oid = if let Some(parent) = parent_oid {
2196 let parent_commit = repo.find_commit(parent).unwrap();
2197 repo.commit(
2198 Some("HEAD"),
2199 &signature,
2200 &signature,
2201 &msg,
2202 &tree,
2203 &[&parent_commit],
2204 )
2205 .unwrap()
2206 } else {
2207 repo.commit(Some("HEAD"), &signature, &signature, &msg, &tree, &[])
2208 .unwrap()
2209 };
2210 parent_oid = Some(oid);
2211 hashes.push(oid.to_string());
2212 }
2213
2214 (temp_dir, hashes)
2215 }
2216
2217 fn init_test_repo_with_commit() -> (tempfile::TempDir, String) {
2221 let (temp_dir, hashes) = init_test_repo_with_n_commits(1);
2222 (temp_dir, hashes.into_iter().next().unwrap())
2223 }
2224
2225 fn make_cmd(commit_range: &str, save_path: std::path::PathBuf) -> TwiddleCommand {
2228 TwiddleCommand {
2229 commit_range: Some(commit_range.to_string()),
2230 auto_apply: false,
2231 allow_pushed: false,
2232 save_only: Some(save_path.to_string_lossy().into_owned()),
2233 use_context: false,
2234 context_dir: None,
2235 work_context: None,
2236 branch_context: None,
2237 no_context: true,
2238 concurrency: 1,
2239 batch_size: None,
2240 no_coherence: true,
2241 no_ai: false,
2242 fresh: false,
2243 refine: false,
2244 check: false,
2245 quiet: true,
2246 ai: crate::cli::ai_backend_args::AiBackendArgs::default(),
2247 }
2248 }
2249
2250 fn batch_amendment_yaml(entries: &[(&str, &str, &str)]) -> String {
2253 let mut out = String::from("amendments:\n");
2254 for (hash, msg, summary) in entries {
2255 out.push_str(&format!(
2256 " - commit: {hash}\n message: '{msg}'\n summary: '{summary}'\n"
2257 ));
2258 }
2259 out
2260 }
2261
2262 #[tokio::test]
2267 async fn execute_with_client_multi_commit_batch_success_covers_line_392() {
2268 let (temp_dir, hashes) = init_test_repo_with_n_commits(3);
2269
2270 let h_mid = &hashes[1];
2272 let h_new = &hashes[2];
2273
2274 let yaml = batch_amendment_yaml(&[
2275 (h_mid, "feat: improved mid", "improved mid summary"),
2276 (h_new, "feat: improved new", "improved new summary"),
2277 ]);
2278 let mock = ConfigurableMockAiClient::new(vec![Ok(yaml)]);
2279 let response_handle = mock.response_handle();
2280 let prompt_handle = mock.prompt_handle();
2281 let client = ClaudeClient::new(Box::new(mock));
2282
2283 let save_path = temp_dir.path().join("amendments.yaml");
2284 let cmd = make_cmd("HEAD~2..HEAD", save_path.clone());
2285
2286 cmd.execute_with_client(temp_dir.path(), client)
2287 .await
2288 .unwrap();
2289
2290 assert_eq!(response_handle.remaining(), 0);
2292 assert_eq!(prompt_handle.request_count(), 1);
2293
2294 let saved = AmendmentFile::load_from_file(&save_path).unwrap();
2297 assert_eq!(saved.amendments.len(), 2);
2298 let summaries: Vec<&str> = saved
2299 .amendments
2300 .iter()
2301 .map(|a| a.summary.as_str())
2302 .collect();
2303 assert!(
2304 summaries.contains(&"improved mid summary"),
2305 "summaries: {summaries:?}"
2306 );
2307 assert!(
2308 summaries.contains(&"improved new summary"),
2309 "summaries: {summaries:?}"
2310 );
2311 }
2312
2313 #[tokio::test]
2319 async fn execute_with_client_multi_commit_split_retry_covers_line_424() {
2320 let (temp_dir, hashes) = init_test_repo_with_n_commits(3);
2321
2322 let h_mid = &hashes[1];
2323 let h_new = &hashes[2];
2324
2325 let mock = ConfigurableMockAiClient::new(vec![
2329 Err(anyhow::anyhow!("simulated batch failure 1")),
2330 Err(anyhow::anyhow!("simulated batch failure 2")),
2331 Err(anyhow::anyhow!("simulated batch failure 3")),
2332 Ok(batch_amendment_yaml(&[(
2333 h_mid,
2334 "feat: solo mid",
2335 "solo mid summary",
2336 )])),
2337 Ok(batch_amendment_yaml(&[(
2338 h_new,
2339 "feat: solo new",
2340 "solo new summary",
2341 )])),
2342 ]);
2343 let response_handle = mock.response_handle();
2344 let prompt_handle = mock.prompt_handle();
2345 let client = ClaudeClient::new(Box::new(mock));
2346
2347 let save_path = temp_dir.path().join("amendments.yaml");
2348 let cmd = make_cmd("HEAD~2..HEAD", save_path.clone());
2349
2350 cmd.execute_with_client(temp_dir.path(), client)
2351 .await
2352 .unwrap();
2353
2354 assert_eq!(response_handle.remaining(), 0);
2356 assert_eq!(prompt_handle.request_count(), 5);
2357
2358 let saved = AmendmentFile::load_from_file(&save_path).unwrap();
2359 assert_eq!(saved.amendments.len(), 2);
2360 let summaries: Vec<&str> = saved
2361 .amendments
2362 .iter()
2363 .map(|a| a.summary.as_str())
2364 .collect();
2365 assert!(
2366 summaries.contains(&"solo mid summary"),
2367 "summaries: {summaries:?}"
2368 );
2369 assert!(
2370 summaries.contains(&"solo new summary"),
2371 "summaries: {summaries:?}"
2372 );
2373 }
2374
2375 fn make_no_ai_cmd(save_path: &std::path::Path) -> TwiddleCommand {
2377 TwiddleCommand {
2378 no_ai: true,
2379 ..make_cmd("HEAD", save_path.to_path_buf())
2380 }
2381 }
2382
2383 fn set_head_message(repo_root: &std::path::Path, message: &str) -> String {
2385 let repo = Repository::open(repo_root).unwrap();
2386 let head = repo.head().unwrap().peel_to_commit().unwrap();
2387 head.amend(Some("HEAD"), None, None, None, Some(message), None)
2388 .unwrap()
2389 .to_string()
2390 }
2391
2392 #[tokio::test]
2396 async fn execute_no_ai_save_only_suggests_type_and_scope() {
2397 let (temp_dir, _) = init_test_repo_with_commit();
2398 let hash = set_head_message(temp_dir.path(), "add widget\n\nWhy it matters.\n");
2399 let context_dir = temp_dir.path().join(".omni-dev");
2400 std::fs::create_dir_all(&context_dir).unwrap();
2401 std::fs::write(
2402 context_dir.join("scopes.yaml"),
2403 "scopes:\n - name: widgets\n description: Widgets\n examples: []\n file_patterns:\n - f0.txt\n",
2404 )
2405 .unwrap();
2406
2407 let save_path = temp_dir.path().join("amendments.yaml");
2408 make_no_ai_cmd(&save_path)
2409 .execute(Some(temp_dir.path()))
2410 .await
2411 .unwrap();
2412
2413 let saved = AmendmentFile::load_from_file(&save_path).unwrap();
2414 assert_eq!(saved.amendments.len(), 1);
2415 let amendment = &saved.amendments[0];
2416 assert_eq!(amendment.commit, hash);
2417 assert_eq!(
2418 amendment.message,
2419 "chore(widgets): add widget\n\nWhy it matters.\n"
2420 );
2421 assert_eq!(amendment.summary, "");
2422 }
2423
2424 #[tokio::test]
2428 async fn execute_no_ai_skips_conforming_commits() {
2429 let (temp_dir, hash) = init_test_repo_with_commit();
2430 let save_path = temp_dir.path().join("amendments.yaml");
2431
2432 make_no_ai_cmd(&save_path)
2433 .execute(Some(temp_dir.path()))
2434 .await
2435 .unwrap();
2436 let saved = AmendmentFile::load_from_file(&save_path).unwrap();
2437 assert!(saved.amendments.is_empty());
2438
2439 let apply = TwiddleCommand {
2440 auto_apply: true,
2441 save_only: None,
2442 ..make_no_ai_cmd(&save_path)
2443 };
2444 apply.execute(Some(temp_dir.path())).await.unwrap();
2445
2446 let repo = Repository::open(temp_dir.path()).unwrap();
2447 let head = repo.head().unwrap().peel_to_commit().unwrap();
2448 assert_eq!(head.id().to_string(), hash, "HEAD must not be rewritten");
2449 }
2450
2451 #[test]
2454 fn deterministic_amendments_filters_unchanged_commits() {
2455 let (temp_dir, hashes) = init_test_repo_with_n_commits(2);
2456 let changed = set_head_message(temp_dir.path(), "tidy things");
2457 let cmd = make_cmd("HEAD~1..HEAD", temp_dir.path().join("unused.yaml"));
2458 let repo_view = cmd.generate_repository_view(temp_dir.path()).unwrap();
2459
2460 let amendments = deterministic_amendments(&repo_view, &[]);
2461
2462 assert_eq!(amendments.len(), 1, "hashes: {hashes:?}");
2463 assert_eq!(amendments[0].commit, changed);
2464 assert_eq!(amendments[0].message, "chore: tidy things");
2465 }
2466}
2467
2468fn format_work_pattern(pattern: &crate::data::context::WorkPattern) -> Option<&'static str> {
2474 use crate::data::context::WorkPattern;
2475 match pattern {
2476 WorkPattern::Sequential => Some("\u{1f504} Pattern: Sequential development"),
2477 WorkPattern::Refactoring => Some("\u{1f9f9} Pattern: Refactoring work"),
2478 WorkPattern::BugHunt => Some("\u{1f41b} Pattern: Bug investigation"),
2479 WorkPattern::Documentation => Some("\u{1f4d6} Pattern: Documentation updates"),
2480 WorkPattern::Configuration => Some("\u{2699}\u{fe0f} Pattern: Configuration changes"),
2481 WorkPattern::Unknown => None,
2482 }
2483}
2484
2485fn format_verbosity_level(level: crate::data::context::VerbosityLevel) -> &'static str {
2487 use crate::data::context::VerbosityLevel;
2488 match level {
2489 VerbosityLevel::Comprehensive => {
2490 "\u{1f4dd} Detail level: Comprehensive (significant changes detected)"
2491 }
2492 VerbosityLevel::Detailed => "\u{1f4dd} Detail level: Detailed",
2493 VerbosityLevel::Concise => "\u{1f4dd} Detail level: Concise",
2494 }
2495}
2496
2497fn format_scope_list(scopes: &[crate::data::context::ScopeDefinition]) -> String {
2499 scopes
2500 .iter()
2501 .map(|s| s.name.as_str())
2502 .collect::<Vec<_>>()
2503 .join(", ")
2504}
2505
2506fn resolve_duplicate_amendments(
2514 amendments: &mut AmendmentFile,
2515 auto_pick: bool,
2516 is_terminal: bool,
2517 reader: &mut (dyn std::io::BufRead + Send),
2518) -> Result<()> {
2519 use std::collections::hash_map::Entry;
2520 use std::collections::{HashMap, HashSet};
2521 use std::io::{self, Write};
2522
2523 if amendments.amendments.len() < 2 {
2524 return Ok(());
2525 }
2526
2527 let mut order: Vec<String> = Vec::new();
2528 let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
2529 for (i, a) in amendments.amendments.iter().enumerate() {
2530 match groups.entry(a.commit.clone()) {
2531 Entry::Vacant(slot) => {
2532 order.push(a.commit.clone());
2533 slot.insert(vec![i]);
2534 }
2535 Entry::Occupied(mut slot) => {
2536 slot.get_mut().push(i);
2537 }
2538 }
2539 }
2540
2541 if groups.values().all(|v| v.len() <= 1) {
2542 return Ok(());
2543 }
2544
2545 let mut drop_indices: HashSet<usize> = HashSet::new();
2546
2547 for hash in &order {
2548 let Some(idxs) = groups.get(hash) else {
2549 continue;
2550 };
2551 if idxs.len() <= 1 {
2552 continue;
2553 }
2554
2555 let short = &hash[..crate::git::SHORT_HASH_LEN.min(hash.len())];
2556
2557 let chosen = if auto_pick {
2558 eprintln!(
2559 "warning: model returned {} duplicate amendments for commit {short}; \
2560 keeping the first.",
2561 idxs.len()
2562 );
2563 idxs[0]
2564 } else if !is_terminal {
2565 eprintln!(
2566 "warning: model returned {} duplicate amendments for commit {short}; \
2567 stdin not interactive — keeping the first.",
2568 idxs.len()
2569 );
2570 idxs[0]
2571 } else {
2572 println!(
2573 "\n⚠️ Model returned {} duplicate amendments for commit {short}:",
2574 idxs.len()
2575 );
2576 for (n, &i) in idxs.iter().enumerate() {
2577 println!("\n [{}] -----", n + 1);
2578 for line in amendments.amendments[i].message.lines() {
2579 println!(" {line}");
2580 }
2581 }
2582 println!();
2583
2584 loop {
2585 print!(
2586 "❓ Which amendment to apply? [1-{}] (default 1) ",
2587 idxs.len()
2588 );
2589 io::stdout().flush()?;
2590
2591 let Some(input) = super::read_interactive_line(reader)? else {
2592 eprintln!("warning: stdin closed; keeping the first amendment.");
2593 break idxs[0];
2594 };
2595
2596 let trimmed = input.trim();
2597 if trimmed.is_empty() {
2598 break idxs[0];
2599 }
2600 match trimmed.parse::<usize>() {
2601 Ok(n) if (1..=idxs.len()).contains(&n) => break idxs[n - 1],
2602 _ => println!(
2603 "Invalid choice. Please enter a number between 1 and {}.",
2604 idxs.len()
2605 ),
2606 }
2607 }
2608 };
2609
2610 for &i in idxs {
2611 if i != chosen {
2612 drop_indices.insert(i);
2613 }
2614 }
2615 }
2616
2617 let kept: Vec<_> = std::mem::take(&mut amendments.amendments)
2618 .into_iter()
2619 .enumerate()
2620 .filter(|(i, _)| !drop_indices.contains(i))
2621 .map(|(_, a)| a)
2622 .collect();
2623 amendments.amendments = kept;
2624
2625 Ok(())
2626}
2627
2628fn deterministic_amendments(
2631 repo_view: &RepositoryView,
2632 scope_defs: &[crate::data::context::ScopeDefinition],
2633) -> Vec<crate::data::amendments::Amendment> {
2634 repo_view
2635 .commits
2636 .iter()
2637 .filter_map(|commit| {
2638 let message = commit
2639 .analysis
2640 .suggest_message(&commit.original_message, scope_defs);
2641 (message.trim_end() != commit.original_message.trim_end()).then(|| {
2642 crate::data::amendments::Amendment {
2643 commit: commit.hash.clone(),
2644 message,
2645 summary: String::new(),
2646 }
2647 })
2648 })
2649 .collect()
2650}
2651
2652fn refine_amendment_scopes(
2655 amendments: &mut AmendmentFile,
2656 repo_view: &RepositoryView,
2657 scope_defs: &[crate::data::context::ScopeDefinition],
2658) {
2659 for amendment in &mut amendments.amendments {
2660 if let Some(commit) = repo_view
2661 .commits
2662 .iter()
2663 .find(|c| c.hash == amendment.commit)
2664 {
2665 let files: Vec<&str> = commit
2666 .analysis
2667 .file_changes
2668 .file_list
2669 .iter()
2670 .map(|f| f.file.as_str())
2671 .collect();
2672 amendment.message =
2673 crate::git::refine_message_scope(&amendment.message, &files, scope_defs);
2674 }
2675 }
2676}
2677
2678#[cfg(test)]
2679#[allow(clippy::unwrap_used, clippy::expect_used)]
2680mod tests {
2681 use super::*;
2682 use crate::data::context::{ScopeDefinition, VerbosityLevel, WorkPattern};
2683
2684 #[test]
2687 fn work_pattern_sequential() {
2688 let result = format_work_pattern(&WorkPattern::Sequential);
2689 assert!(result.is_some());
2690 assert!(result.unwrap().contains("Sequential development"));
2691 }
2692
2693 #[test]
2694 fn work_pattern_refactoring() {
2695 let result = format_work_pattern(&WorkPattern::Refactoring);
2696 assert!(result.is_some());
2697 assert!(result.unwrap().contains("Refactoring work"));
2698 }
2699
2700 #[test]
2701 fn work_pattern_bug_hunt() {
2702 let result = format_work_pattern(&WorkPattern::BugHunt);
2703 assert!(result.is_some());
2704 assert!(result.unwrap().contains("Bug investigation"));
2705 }
2706
2707 #[test]
2708 fn work_pattern_docs() {
2709 let result = format_work_pattern(&WorkPattern::Documentation);
2710 assert!(result.is_some());
2711 assert!(result.unwrap().contains("Documentation updates"));
2712 }
2713
2714 #[test]
2715 fn work_pattern_config() {
2716 let result = format_work_pattern(&WorkPattern::Configuration);
2717 assert!(result.is_some());
2718 assert!(result.unwrap().contains("Configuration changes"));
2719 }
2720
2721 #[test]
2722 fn work_pattern_unknown() {
2723 assert!(format_work_pattern(&WorkPattern::Unknown).is_none());
2724 }
2725
2726 #[test]
2729 fn verbosity_comprehensive() {
2730 let label = format_verbosity_level(VerbosityLevel::Comprehensive);
2731 assert!(label.contains("Comprehensive"));
2732 assert!(label.contains("significant changes"));
2733 }
2734
2735 #[test]
2736 fn verbosity_detailed() {
2737 let label = format_verbosity_level(VerbosityLevel::Detailed);
2738 assert!(label.contains("Detailed"));
2739 }
2740
2741 #[test]
2742 fn verbosity_concise() {
2743 let label = format_verbosity_level(VerbosityLevel::Concise);
2744 assert!(label.contains("Concise"));
2745 }
2746
2747 #[test]
2750 fn scope_list_single() {
2751 let scopes = vec![ScopeDefinition {
2752 name: "cli".to_string(),
2753 description: String::new(),
2754 examples: vec![],
2755 file_patterns: vec![],
2756 }];
2757 assert_eq!(format_scope_list(&scopes), "cli");
2758 }
2759
2760 #[test]
2761 fn scope_list_multiple() {
2762 let scopes = vec![
2763 ScopeDefinition {
2764 name: "cli".to_string(),
2765 description: String::new(),
2766 examples: vec![],
2767 file_patterns: vec![],
2768 },
2769 ScopeDefinition {
2770 name: "git".to_string(),
2771 description: String::new(),
2772 examples: vec![],
2773 file_patterns: vec![],
2774 },
2775 ScopeDefinition {
2776 name: "docs".to_string(),
2777 description: String::new(),
2778 examples: vec![],
2779 file_patterns: vec![],
2780 },
2781 ];
2782 assert_eq!(format_scope_list(&scopes), "cli, git, docs");
2783 }
2784
2785 #[test]
2788 fn context_dir_default() {
2789 let result = crate::claude::context::resolve_context_dir(None);
2790 assert!(
2792 result.ends_with(".omni-dev"),
2793 "expected path ending in .omni-dev, got {result:?}"
2794 );
2795 }
2796
2797 #[test]
2798 fn context_dir_override() {
2799 let custom = std::path::PathBuf::from("custom-dir");
2800 let result = crate::claude::context::resolve_context_dir(Some(&custom));
2801 assert_eq!(result, custom);
2802 }
2803
2804 fn parse_twiddle(args: &[&str]) -> TwiddleCommand {
2807 let mut full_args = vec!["twiddle"];
2808 full_args.extend_from_slice(args);
2809 TwiddleCommand::try_parse_from(full_args).unwrap()
2810 }
2811
2812 #[test]
2813 fn default_is_fresh() {
2814 let cmd = parse_twiddle(&[]);
2815 assert!(cmd.is_fresh(), "default should be fresh mode");
2816 }
2817
2818 #[test]
2819 fn refine_disables_fresh() {
2820 let cmd = parse_twiddle(&["--refine"]);
2821 assert!(!cmd.is_fresh(), "--refine should disable fresh mode");
2822 }
2823
2824 #[test]
2825 fn explicit_fresh_is_fresh() {
2826 let cmd = parse_twiddle(&["--fresh"]);
2827 assert!(cmd.is_fresh(), "--fresh should be fresh mode");
2828 }
2829
2830 #[test]
2831 fn fresh_and_refine_conflict() {
2832 let result = TwiddleCommand::try_parse_from(["twiddle", "--fresh", "--refine"]);
2833 assert!(result.is_err(), "--fresh and --refine should conflict");
2834 }
2835
2836 fn make_twiddle_cmd() -> TwiddleCommand {
2839 TwiddleCommand {
2840 commit_range: None,
2841 auto_apply: false,
2842 allow_pushed: false,
2843 save_only: None,
2844 use_context: false,
2845 context_dir: None,
2846 work_context: None,
2847 branch_context: None,
2848 no_context: true,
2849 concurrency: 4,
2850 batch_size: None,
2851 no_coherence: true,
2852 no_ai: false,
2853 fresh: false,
2854 refine: false,
2855 check: false,
2856 quiet: false,
2857 ai: crate::cli::ai_backend_args::AiBackendArgs::default(),
2858 }
2859 }
2860
2861 fn make_twiddle_commit(hash: &str) -> (crate::git::CommitInfo, tempfile::NamedTempFile) {
2862 use crate::git::commit::FileChanges;
2863 use crate::git::{CommitAnalysis, CommitInfo};
2864 let tmp = tempfile::NamedTempFile::new().unwrap();
2865 let commit = CommitInfo {
2866 hash: hash.to_string(),
2867 author: "Test <test@test.com>".to_string(),
2868 date: chrono::Utc::now().fixed_offset(),
2869 original_message: format!("feat: commit {hash}"),
2870 in_main_branches: vec![],
2871 analysis: CommitAnalysis {
2872 detected_type: "feat".to_string(),
2873 detected_scope: String::new(),
2874 proposed_message: format!("feat: commit {hash}"),
2875 file_changes: FileChanges {
2876 total_files: 0,
2877 files_added: 0,
2878 files_deleted: 0,
2879 file_list: vec![],
2880 },
2881 diff_summary: String::new(),
2882 diff_file: tmp.path().to_string_lossy().to_string(),
2883 file_diffs: Vec::new(),
2884 },
2885 };
2886 (commit, tmp)
2887 }
2888
2889 fn make_twiddle_repo_view(commits: Vec<crate::git::CommitInfo>) -> crate::data::RepositoryView {
2890 use crate::data::{AiInfo, FieldExplanation, RepositoryView, WorkingDirectoryInfo};
2891 RepositoryView {
2892 versions: None,
2893 explanation: FieldExplanation::default(),
2894 working_directory: WorkingDirectoryInfo {
2895 clean: true,
2896 untracked_changes: vec![],
2897 },
2898 remotes: vec![],
2899 ai: AiInfo {
2900 scratch: String::new(),
2901 },
2902 branch_info: None,
2903 pr_template: None,
2904 pr_template_location: None,
2905 branch_prs: None,
2906 commits,
2907 }
2908 }
2909
2910 fn twiddle_check_yaml(hash: &str) -> String {
2911 format!("checks:\n - commit: {hash}\n passes: true\n issues: []\n")
2912 }
2913
2914 fn make_mock_client(
2915 responses: Vec<anyhow::Result<String>>,
2916 ) -> crate::claude::client::ClaudeClient {
2917 crate::claude::client::ClaudeClient::new(Box::new(
2918 crate::claude::test_utils::ConfigurableMockAiClient::new(responses),
2919 ))
2920 }
2921
2922 #[tokio::test]
2923 async fn check_commits_map_reduce_single_commit_succeeds() {
2924 let (commit, _tmp) = make_twiddle_commit("abc00000");
2926 let cmd = make_twiddle_cmd();
2927 let repo_view = make_twiddle_repo_view(vec![commit]);
2928 let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2929 let result = cmd
2930 .check_commits_map_reduce(&client, &repo_view, None, &[])
2931 .await;
2932 assert!(result.is_ok());
2933 assert_eq!(result.unwrap().commits.len(), 1);
2934 }
2935
2936 #[tokio::test]
2937 async fn check_commits_map_reduce_batch_fails_split_retry_both_succeed() {
2938 let (c1, _t1) = make_twiddle_commit("abc00000");
2942 let (c2, _t2) = make_twiddle_commit("def00000");
2943 let cmd = make_twiddle_cmd();
2944 let repo_view = make_twiddle_repo_view(vec![c1, c2]);
2945 let mut responses: Vec<anyhow::Result<String>> =
2946 (0..3).map(|_| Err(anyhow::anyhow!("batch fail"))).collect();
2947 responses.push(Ok(twiddle_check_yaml("abc00000")));
2948 responses.push(Ok(twiddle_check_yaml("def00000")));
2949 let client = make_mock_client(responses);
2950 let result = cmd
2951 .check_commits_map_reduce(&client, &repo_view, None, &[])
2952 .await;
2953 assert!(result.is_ok());
2954 assert_eq!(result.unwrap().commits.len(), 2);
2955 }
2956
2957 #[tokio::test]
2960 async fn interactive_retry_twiddle_skip_immediately() {
2961 let (commit, _tmp) = make_twiddle_commit("abc00000");
2963 let cmd = make_twiddle_cmd();
2964 let repo_view = make_twiddle_repo_view(vec![commit]);
2965 let client = make_mock_client(vec![]);
2966 let mut failed = vec![0usize];
2967 let mut successes = vec![];
2968 let mut stdin = std::io::Cursor::new(b"s\n" as &[u8]);
2969 cmd.run_interactive_retry_twiddle_check(
2970 &mut failed,
2971 &repo_view,
2972 &client,
2973 None,
2974 &[],
2975 &mut successes,
2976 &mut stdin,
2977 )
2978 .await
2979 .unwrap();
2980 assert_eq!(
2981 failed,
2982 vec![0],
2983 "skip should leave failed_indices unchanged"
2984 );
2985 assert!(successes.is_empty());
2986 }
2987
2988 #[tokio::test]
2989 async fn interactive_retry_twiddle_retry_succeeds() {
2990 let (commit, _tmp) = make_twiddle_commit("abc00000");
2992 let cmd = make_twiddle_cmd();
2993 let repo_view = make_twiddle_repo_view(vec![commit]);
2994 let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
2995 let mut failed = vec![0usize];
2996 let mut successes = vec![];
2997 let mut stdin = std::io::Cursor::new(b"r\n" as &[u8]);
2998 cmd.run_interactive_retry_twiddle_check(
2999 &mut failed,
3000 &repo_view,
3001 &client,
3002 None,
3003 &[],
3004 &mut successes,
3005 &mut stdin,
3006 )
3007 .await
3008 .unwrap();
3009 assert!(
3010 failed.is_empty(),
3011 "retry succeeded → failed_indices cleared"
3012 );
3013 assert_eq!(successes.len(), 1);
3014 }
3015
3016 #[tokio::test]
3017 async fn interactive_retry_twiddle_default_input_retries() {
3018 let (commit, _tmp) = make_twiddle_commit("abc00000");
3020 let cmd = make_twiddle_cmd();
3021 let repo_view = make_twiddle_repo_view(vec![commit]);
3022 let client = make_mock_client(vec![Ok(twiddle_check_yaml("abc00000"))]);
3023 let mut failed = vec![0usize];
3024 let mut successes = vec![];
3025 let mut stdin = std::io::Cursor::new(b"\n" as &[u8]);
3026 cmd.run_interactive_retry_twiddle_check(
3027 &mut failed,
3028 &repo_view,
3029 &client,
3030 None,
3031 &[],
3032 &mut successes,
3033 &mut stdin,
3034 )
3035 .await
3036 .unwrap();
3037 assert!(failed.is_empty());
3038 assert_eq!(successes.len(), 1);
3039 }
3040
3041 #[tokio::test]
3042 async fn interactive_retry_twiddle_still_fails_then_skip() {
3043 let (commit, _tmp) = make_twiddle_commit("abc00000");
3045 let cmd = make_twiddle_cmd();
3046 let repo_view = make_twiddle_repo_view(vec![commit]);
3047 let responses = (0..3).map(|_| Err(anyhow::anyhow!("mock fail"))).collect();
3049 let client = make_mock_client(responses);
3050 let mut failed = vec![0usize];
3051 let mut successes = vec![];
3052 let mut stdin = std::io::Cursor::new(b"r\ns\n" as &[u8]);
3053 cmd.run_interactive_retry_twiddle_check(
3054 &mut failed,
3055 &repo_view,
3056 &client,
3057 None,
3058 &[],
3059 &mut successes,
3060 &mut stdin,
3061 )
3062 .await
3063 .unwrap();
3064 assert_eq!(failed, vec![0], "commit still failed after retry");
3065 assert!(successes.is_empty());
3066 }
3067
3068 #[tokio::test]
3069 async fn interactive_retry_twiddle_invalid_input_then_skip() {
3070 let (commit, _tmp) = make_twiddle_commit("abc00000");
3072 let cmd = make_twiddle_cmd();
3073 let repo_view = make_twiddle_repo_view(vec![commit]);
3074 let client = make_mock_client(vec![]);
3075 let mut failed = vec![0usize];
3076 let mut successes = vec![];
3077 let mut stdin = std::io::Cursor::new(b"x\ns\n" as &[u8]);
3078 cmd.run_interactive_retry_twiddle_check(
3079 &mut failed,
3080 &repo_view,
3081 &client,
3082 None,
3083 &[],
3084 &mut successes,
3085 &mut stdin,
3086 )
3087 .await
3088 .unwrap();
3089 assert_eq!(failed, vec![0]);
3090 assert!(successes.is_empty());
3091 }
3092
3093 #[tokio::test]
3094 async fn interactive_retry_twiddle_eof_breaks_immediately() {
3095 let (commit, _tmp) = make_twiddle_commit("abc00000");
3098 let cmd = make_twiddle_cmd();
3099 let repo_view = make_twiddle_repo_view(vec![commit]);
3100 let client = make_mock_client(vec![]); let mut failed = vec![0usize];
3102 let mut successes = vec![];
3103 let mut stdin = std::io::Cursor::new(b"" as &[u8]);
3104 cmd.run_interactive_retry_twiddle_check(
3105 &mut failed,
3106 &repo_view,
3107 &client,
3108 None,
3109 &[],
3110 &mut successes,
3111 &mut stdin,
3112 )
3113 .await
3114 .unwrap();
3115 assert_eq!(failed, vec![0], "EOF should leave failed_indices unchanged");
3116 assert!(successes.is_empty());
3117 }
3118
3119 fn make_amendment_file() -> crate::data::amendments::AmendmentFile {
3122 crate::data::amendments::AmendmentFile {
3123 amendments: vec![crate::data::amendments::Amendment {
3124 commit: "abc0000000000000000000000000000000000001".to_string(),
3125 message: "feat: improved commit message".to_string(),
3126 summary: String::new(),
3127 }],
3128 }
3129 }
3130
3131 #[test]
3132 fn handle_amendments_file_non_terminal_returns_false() {
3133 let cmd = make_twiddle_cmd();
3135 let amendments = make_amendment_file();
3136 let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3137 let mut reader = std::io::Cursor::new(b"" as &[u8]);
3138 let result = cmd
3139 .handle_amendments_file(dummy_path, &amendments, false, &mut reader)
3140 .unwrap();
3141 assert!(!result, "non-terminal should return false");
3142 }
3143
3144 #[test]
3145 fn handle_amendments_file_eof_returns_false() {
3146 let cmd = make_twiddle_cmd();
3148 let amendments = make_amendment_file();
3149 let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3150 let mut reader = std::io::Cursor::new(b"" as &[u8]);
3151 let result = cmd
3152 .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
3153 .unwrap();
3154 assert!(!result, "EOF should return false");
3155 }
3156
3157 #[test]
3158 fn handle_amendments_file_quit_returns_false() {
3159 let cmd = make_twiddle_cmd();
3161 let amendments = make_amendment_file();
3162 let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3163 let mut reader = std::io::Cursor::new(b"q\n" as &[u8]);
3164 let result = cmd
3165 .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
3166 .unwrap();
3167 assert!(!result, "quit should return false");
3168 }
3169
3170 #[test]
3171 fn handle_amendments_file_apply_returns_true() {
3172 let cmd = make_twiddle_cmd();
3174 let amendments = make_amendment_file();
3175 let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3176 let mut reader = std::io::Cursor::new(b"a\n" as &[u8]);
3177 let result = cmd
3178 .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
3179 .unwrap();
3180 assert!(result, "apply should return true");
3181 }
3182
3183 #[test]
3184 fn handle_amendments_file_invalid_then_quit_returns_false() {
3185 let cmd = make_twiddle_cmd();
3187 let amendments = make_amendment_file();
3188 let dummy_path = std::path::Path::new("/tmp/dummy_amendments.yaml");
3189 let mut reader = std::io::Cursor::new(b"x\nq\n" as &[u8]);
3190 let result = cmd
3191 .handle_amendments_file(dummy_path, &amendments, true, &mut reader)
3192 .unwrap();
3193 assert!(!result, "invalid then quit should return false");
3194 }
3195
3196 const HASH_40: &str = "abc0000000000000000000000000000000000000";
3200
3201 fn twiddle_amendment_yaml(hash: &str) -> String {
3202 format!("amendments:\n - commit: \"{hash}\"\n message: \"feat: improved message\"\n")
3203 }
3204
3205 #[tokio::test]
3206 async fn retry_generate_amendments_non_terminal_returns_immediately() {
3207 let (commit, _tmp) = make_twiddle_commit("abc00000");
3209 let cmd = make_twiddle_cmd();
3210 let repo_view = make_twiddle_repo_view(vec![commit]);
3211 let client = make_mock_client(vec![]); let mut failed = vec![0usize];
3213 let mut successes = vec![];
3214 let mut reader = std::io::Cursor::new(b"" as &[u8]);
3215 cmd.run_interactive_retry_generate_amendments(
3216 &mut failed,
3217 &repo_view,
3218 &client,
3219 None,
3220 false,
3221 &mut successes,
3222 false, &mut reader,
3224 )
3225 .await
3226 .unwrap();
3227 assert_eq!(
3228 failed,
3229 vec![0],
3230 "non-terminal should leave failed unchanged"
3231 );
3232 assert!(successes.is_empty());
3233 }
3234
3235 #[tokio::test]
3236 async fn retry_generate_amendments_eof_breaks_immediately() {
3237 let (commit, _tmp) = make_twiddle_commit("abc00000");
3239 let cmd = make_twiddle_cmd();
3240 let repo_view = make_twiddle_repo_view(vec![commit]);
3241 let client = make_mock_client(vec![]); let mut failed = vec![0usize];
3243 let mut successes = vec![];
3244 let mut reader = std::io::Cursor::new(b"" as &[u8]);
3245 cmd.run_interactive_retry_generate_amendments(
3246 &mut failed,
3247 &repo_view,
3248 &client,
3249 None,
3250 false,
3251 &mut successes,
3252 true, &mut reader,
3254 )
3255 .await
3256 .unwrap();
3257 assert_eq!(failed, vec![0], "EOF should leave failed unchanged");
3258 assert!(successes.is_empty());
3259 }
3260
3261 #[tokio::test]
3262 async fn retry_generate_amendments_skip_breaks_immediately() {
3263 let (commit, _tmp) = make_twiddle_commit("abc00000");
3265 let cmd = make_twiddle_cmd();
3266 let repo_view = make_twiddle_repo_view(vec![commit]);
3267 let client = make_mock_client(vec![]); let mut failed = vec![0usize];
3269 let mut successes = vec![];
3270 let mut reader = std::io::Cursor::new(b"s\n" as &[u8]);
3271 cmd.run_interactive_retry_generate_amendments(
3272 &mut failed,
3273 &repo_view,
3274 &client,
3275 None,
3276 false,
3277 &mut successes,
3278 true,
3279 &mut reader,
3280 )
3281 .await
3282 .unwrap();
3283 assert_eq!(failed, vec![0], "skip should leave failed unchanged");
3284 assert!(successes.is_empty());
3285 }
3286
3287 #[tokio::test]
3288 async fn retry_generate_amendments_invalid_then_skip() {
3289 let (commit, _tmp) = make_twiddle_commit("abc00000");
3291 let cmd = make_twiddle_cmd();
3292 let repo_view = make_twiddle_repo_view(vec![commit]);
3293 let client = make_mock_client(vec![]);
3294 let mut failed = vec![0usize];
3295 let mut successes = vec![];
3296 let mut reader = std::io::Cursor::new(b"x\ns\n" as &[u8]);
3297 cmd.run_interactive_retry_generate_amendments(
3298 &mut failed,
3299 &repo_view,
3300 &client,
3301 None,
3302 false,
3303 &mut successes,
3304 true,
3305 &mut reader,
3306 )
3307 .await
3308 .unwrap();
3309 assert_eq!(failed, vec![0]);
3310 assert!(successes.is_empty());
3311 }
3312
3313 #[tokio::test]
3314 async fn retry_generate_amendments_retry_fails_then_skip() {
3315 let (commit, _tmp) = make_twiddle_commit("abc00000");
3317 let cmd = make_twiddle_cmd();
3318 let repo_view = make_twiddle_repo_view(vec![commit]);
3319 let client = make_mock_client(vec![Err(anyhow::anyhow!("mock fail"))]);
3320 let mut failed = vec![0usize];
3321 let mut successes = vec![];
3322 let mut reader = std::io::Cursor::new(b"r\ns\n" as &[u8]);
3323 cmd.run_interactive_retry_generate_amendments(
3324 &mut failed,
3325 &repo_view,
3326 &client,
3327 None,
3328 false,
3329 &mut successes,
3330 true,
3331 &mut reader,
3332 )
3333 .await
3334 .unwrap();
3335 assert_eq!(failed, vec![0], "commit still failed after retry");
3336 assert!(successes.is_empty());
3337 }
3338
3339 #[tokio::test]
3340 async fn retry_generate_amendments_retry_succeeds() {
3341 let (commit, _tmp) = make_twiddle_commit(HASH_40);
3343 let cmd = make_twiddle_cmd();
3344 let repo_view = make_twiddle_repo_view(vec![commit]);
3345 let client = make_mock_client(vec![Ok(twiddle_amendment_yaml(HASH_40))]);
3346 let mut failed = vec![0usize];
3347 let mut successes = vec![];
3348 let mut reader = std::io::Cursor::new(b"r\n" as &[u8]);
3349 cmd.run_interactive_retry_generate_amendments(
3350 &mut failed,
3351 &repo_view,
3352 &client,
3353 None,
3354 false,
3355 &mut successes,
3356 true,
3357 &mut reader,
3358 )
3359 .await
3360 .unwrap();
3361 assert!(failed.is_empty(), "retry succeeded → failed cleared");
3362 assert_eq!(successes.len(), 1);
3363 }
3364
3365 #[test]
3366 fn refine_amendment_scopes_replaces_scope_from_file_patterns() {
3367 use crate::data::amendments::Amendment;
3368 use crate::data::context::ScopeDefinition;
3369 use crate::git::commit::FileChange;
3370
3371 let (mut commit, _tmp) = make_twiddle_commit("aaa00000");
3373 commit.analysis.file_changes.file_list = vec![FileChange {
3374 status: "M".to_string(),
3375 file: "src/cli/git/twiddle.rs".to_string(),
3376 }];
3377
3378 let repo_view = make_twiddle_repo_view(vec![commit]);
3379
3380 let scope_defs = vec![ScopeDefinition {
3381 name: "cli".to_string(),
3382 description: "CLI commands".to_string(),
3383 examples: vec![],
3384 file_patterns: vec!["src/cli/**".to_string()],
3385 }];
3386
3387 let mut amendments = AmendmentFile {
3388 amendments: vec![Amendment {
3389 commit: "aaa00000".to_string(),
3390 message: "fix(wrong-scope): tweak something".to_string(),
3391 summary: String::new(),
3392 }],
3393 };
3394
3395 refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
3396
3397 assert_eq!(
3398 amendments.amendments[0].message,
3399 "fix(cli): tweak something",
3400 );
3401 }
3402
3403 #[test]
3404 fn refine_amendment_scopes_no_match_leaves_message_unchanged() {
3405 use crate::data::amendments::Amendment;
3406
3407 let (commit, _tmp) = make_twiddle_commit("bbb00000");
3408 let repo_view = make_twiddle_repo_view(vec![commit]);
3409
3410 let mut amendments = AmendmentFile {
3411 amendments: vec![Amendment {
3412 commit: "bbb00000".to_string(),
3413 message: "feat(stuff): add feature".to_string(),
3414 summary: String::new(),
3415 }],
3416 };
3417
3418 refine_amendment_scopes(&mut amendments, &repo_view, &[]);
3420
3421 assert_eq!(amendments.amendments[0].message, "feat(stuff): add feature",);
3422 }
3423
3424 fn dup_hash(byte: char) -> String {
3427 std::iter::repeat_n(byte, 40).collect()
3428 }
3429
3430 fn dup_amendments(items: &[(&str, &str)]) -> AmendmentFile {
3431 use crate::data::amendments::Amendment;
3432 AmendmentFile {
3433 amendments: items
3434 .iter()
3435 .map(|(hash, msg)| Amendment {
3436 commit: (*hash).to_string(),
3437 message: (*msg).to_string(),
3438 summary: String::new(),
3439 })
3440 .collect(),
3441 }
3442 }
3443
3444 #[test]
3445 fn resolve_duplicates_empty_is_noop() {
3446 let mut af = AmendmentFile { amendments: vec![] };
3447 let mut reader = std::io::Cursor::new(b"" as &[u8]);
3448 resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3449 assert!(af.amendments.is_empty());
3450 }
3451
3452 #[test]
3453 fn resolve_duplicates_single_is_noop() {
3454 let h = dup_hash('a');
3455 let mut af = dup_amendments(&[(&h, "feat: only")]);
3456 let mut reader = std::io::Cursor::new(b"" as &[u8]);
3457 resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3458 assert_eq!(af.amendments.len(), 1);
3459 assert_eq!(af.amendments[0].message, "feat: only");
3460 }
3461
3462 #[test]
3463 fn resolve_duplicates_no_dups_unchanged() {
3464 let h_a = dup_hash('a');
3465 let h_b = dup_hash('b');
3466 let mut af = dup_amendments(&[(&h_a, "feat: a"), (&h_b, "feat: b")]);
3467 let mut reader = std::io::Cursor::new(b"" as &[u8]);
3468 resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3469 assert_eq!(af.amendments.len(), 2);
3470 assert_eq!(af.amendments[0].message, "feat: a");
3471 assert_eq!(af.amendments[1].message, "feat: b");
3472 }
3473
3474 #[test]
3475 fn resolve_duplicates_auto_pick_keeps_first() {
3476 let h = dup_hash('a');
3477 let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3478 let mut reader = std::io::Cursor::new(b"" as &[u8]);
3479 resolve_duplicate_amendments(&mut af, true, true, &mut reader).unwrap();
3480 assert_eq!(af.amendments.len(), 1);
3481 assert_eq!(af.amendments[0].message, "feat: first");
3482 }
3483
3484 #[test]
3485 fn resolve_duplicates_non_terminal_keeps_first() {
3486 let h = dup_hash('a');
3487 let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3488 let mut reader = std::io::Cursor::new(b"" as &[u8]);
3489 resolve_duplicate_amendments(&mut af, false, false, &mut reader).unwrap();
3490 assert_eq!(af.amendments.len(), 1);
3491 assert_eq!(af.amendments[0].message, "feat: first");
3492 }
3493
3494 #[test]
3495 fn resolve_duplicates_prompt_picks_second() {
3496 let h = dup_hash('a');
3497 let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3498 let mut reader = std::io::Cursor::new(b"2\n" as &[u8]);
3499 resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3500 assert_eq!(af.amendments.len(), 1);
3501 assert_eq!(af.amendments[0].message, "feat: second");
3502 }
3503
3504 #[test]
3505 fn resolve_duplicates_prompt_default_picks_first() {
3506 let h = dup_hash('a');
3507 let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3508 let mut reader = std::io::Cursor::new(b"\n" as &[u8]);
3509 resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3510 assert_eq!(af.amendments.len(), 1);
3511 assert_eq!(af.amendments[0].message, "feat: first");
3512 }
3513
3514 #[test]
3515 fn resolve_duplicates_prompt_invalid_then_valid() {
3516 let h = dup_hash('a');
3517 let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3518 let mut reader = std::io::Cursor::new(b"x\n9\n2\n" as &[u8]);
3519 resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3520 assert_eq!(af.amendments.len(), 1);
3521 assert_eq!(af.amendments[0].message, "feat: second");
3522 }
3523
3524 #[test]
3525 fn resolve_duplicates_prompt_eof_keeps_first() {
3526 let h = dup_hash('a');
3527 let mut af = dup_amendments(&[(&h, "feat: first"), (&h, "feat: second")]);
3528 let mut reader = std::io::Cursor::new(b"" as &[u8]);
3529 resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3530 assert_eq!(af.amendments.len(), 1);
3531 assert_eq!(af.amendments[0].message, "feat: first");
3532 }
3533
3534 #[test]
3535 fn resolve_duplicates_preserves_unique_amendments_order() {
3536 let h_a = dup_hash('a');
3537 let h_b = dup_hash('b');
3538 let h_c = dup_hash('c');
3539 let mut af = dup_amendments(&[
3540 (&h_a, "feat: a1"),
3541 (&h_b, "feat: b"),
3542 (&h_a, "feat: a2"),
3543 (&h_c, "feat: c"),
3544 ]);
3545 let mut reader = std::io::Cursor::new(b"" as &[u8]);
3546 resolve_duplicate_amendments(&mut af, true, true, &mut reader).unwrap();
3547 assert_eq!(af.amendments.len(), 3);
3548 assert_eq!(af.amendments[0].commit, h_a);
3549 assert_eq!(af.amendments[0].message, "feat: a1");
3550 assert_eq!(af.amendments[1].commit, h_b);
3551 assert_eq!(af.amendments[2].commit, h_c);
3552 }
3553
3554 #[test]
3555 fn resolve_duplicates_three_way_picks_third() {
3556 let h = dup_hash('a');
3557 let mut af = dup_amendments(&[
3558 (&h, "feat: first"),
3559 (&h, "feat: second"),
3560 (&h, "feat: third"),
3561 ]);
3562 let mut reader = std::io::Cursor::new(b"3\n" as &[u8]);
3563 resolve_duplicate_amendments(&mut af, false, true, &mut reader).unwrap();
3564 assert_eq!(af.amendments.len(), 1);
3565 assert_eq!(af.amendments[0].message, "feat: third");
3566 }
3567
3568 #[test]
3569 fn refine_amendment_scopes_skips_unknown_commits() {
3570 use crate::data::amendments::Amendment;
3571 use crate::data::context::ScopeDefinition;
3572
3573 let (commit, _tmp) = make_twiddle_commit("ccc00000");
3574 let repo_view = make_twiddle_repo_view(vec![commit]);
3575
3576 let scope_defs = vec![ScopeDefinition {
3577 name: "cli".to_string(),
3578 description: "CLI".to_string(),
3579 examples: vec![],
3580 file_patterns: vec!["src/cli/**".to_string()],
3581 }];
3582
3583 let mut amendments = AmendmentFile {
3584 amendments: vec![Amendment {
3585 commit: "unknown_hash".to_string(),
3586 message: "fix(wrong): something".to_string(),
3587 summary: String::new(),
3588 }],
3589 };
3590
3591 refine_amendment_scopes(&mut amendments, &repo_view, &scope_defs);
3592
3593 assert_eq!(amendments.amendments[0].message, "fix(wrong): something",);
3595 }
3596}