1use anyhow::{Context, Result};
4use clap::Parser;
5use tracing::debug;
6
7use super::parse_beta_header;
8
9#[derive(Parser)]
11pub struct TwiddleCommand {
12 #[arg(value_name = "COMMIT_RANGE")]
14 pub commit_range: Option<String>,
15
16 #[arg(long)]
18 pub model: Option<String>,
19
20 #[arg(long, value_name = "KEY:VALUE")]
23 pub beta_header: Option<String>,
24
25 #[arg(long)]
27 pub auto_apply: bool,
28
29 #[arg(long, value_name = "FILE")]
31 pub save_only: Option<String>,
32
33 #[arg(long, default_value = "true")]
35 pub use_context: bool,
36
37 #[arg(long)]
39 pub context_dir: Option<std::path::PathBuf>,
40
41 #[arg(long)]
43 pub work_context: Option<String>,
44
45 #[arg(long)]
47 pub branch_context: Option<String>,
48
49 #[arg(long)]
51 pub no_context: bool,
52
53 #[arg(long, default_value = "4", hide = true)]
55 pub batch_size: usize,
56
57 #[arg(long, default_value = "4")]
59 pub concurrency: usize,
60
61 #[arg(long)]
63 pub no_coherence: bool,
64
65 #[arg(long)]
67 pub no_ai: bool,
68
69 #[arg(long)]
71 pub fresh: bool,
72
73 #[arg(long)]
75 pub check: bool,
76}
77
78impl TwiddleCommand {
79 pub async fn execute(self) -> Result<()> {
81 if self.no_ai {
83 return self.execute_no_ai().await;
84 }
85
86 let ai_info = crate::utils::check_ai_command_prerequisites(self.model.as_deref())?;
88 println!(
89 "â {} credentials verified (model: {})",
90 ai_info.provider, ai_info.model
91 );
92
93 crate::utils::preflight::check_working_directory_clean()?;
95 println!("â Working directory is clean");
96
97 let use_contextual = self.use_context && !self.no_context;
99
100 if use_contextual {
101 println!(
102 "đĒ Starting AI-powered commit message improvement with contextual intelligence..."
103 );
104 } else {
105 println!("đĒ Starting AI-powered commit message improvement...");
106 }
107
108 let mut full_repo_view = self.generate_repository_view().await?;
110
111 if full_repo_view.commits.len() > 1 {
113 return self
114 .execute_with_map_reduce(use_contextual, full_repo_view)
115 .await;
116 }
117
118 let context = if use_contextual {
120 Some(self.collect_context(&full_repo_view).await?)
121 } else {
122 None
123 };
124
125 let scope_defs = match &context {
127 Some(ctx) => ctx.project.valid_scopes.clone(),
128 None => self.load_check_scopes(),
129 };
130 for commit in &mut full_repo_view.commits {
131 commit.analysis.refine_scope(&scope_defs);
132 }
133
134 if let Some(ref ctx) = context {
136 self.show_context_summary(ctx)?;
137 }
138
139 let beta = self
141 .beta_header
142 .as_deref()
143 .map(parse_beta_header)
144 .transpose()?;
145 let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
146
147 self.show_model_info_from_client(&claude_client)?;
149
150 if self.fresh {
152 println!("đ Fresh mode: ignoring existing commit messages...");
153 }
154 if use_contextual && context.is_some() {
155 println!("đ¤ Analyzing commits with enhanced contextual intelligence...");
156 } else {
157 println!("đ¤ Analyzing commits with Claude AI...");
158 }
159
160 let amendments = if let Some(ctx) = context {
161 claude_client
162 .generate_contextual_amendments_with_options(&full_repo_view, &ctx, self.fresh)
163 .await?
164 } else {
165 claude_client
166 .generate_amendments_with_options(&full_repo_view, self.fresh)
167 .await?
168 };
169
170 if let Some(save_path) = self.save_only {
172 amendments.save_to_file(save_path)?;
173 println!("đž Amendments saved to file");
174 return Ok(());
175 }
176
177 if !amendments.amendments.is_empty() {
179 let temp_dir = tempfile::tempdir()?;
181 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
182 amendments.save_to_file(&amendments_file)?;
183
184 if !self.auto_apply && !self.handle_amendments_file(&amendments_file, &amendments)? {
186 println!("â Amendment cancelled by user");
187 return Ok(());
188 }
189
190 self.apply_amendments_from_file(&amendments_file).await?;
192 println!("â
Commit messages improved successfully!");
193
194 if self.check {
196 self.run_post_twiddle_check().await?;
197 }
198 } else {
199 println!("⨠No commits found to process!");
200 }
201
202 Ok(())
203 }
204
205 async fn execute_with_map_reduce(
212 &self,
213 use_contextual: bool,
214 mut full_repo_view: crate::data::RepositoryView,
215 ) -> Result<()> {
216 use std::sync::atomic::{AtomicUsize, Ordering};
217 use std::sync::Arc;
218
219 use crate::claude::batch;
220 use crate::claude::token_budget;
221 use crate::data::amendments::AmendmentFile;
222
223 let concurrency = self.concurrency;
224
225 let beta = self
227 .beta_header
228 .as_deref()
229 .map(parse_beta_header)
230 .transpose()?;
231 let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
232
233 self.show_model_info_from_client(&claude_client)?;
235
236 if self.fresh {
237 println!("đ Fresh mode: ignoring existing commit messages...");
238 }
239
240 let total_commits = full_repo_view.commits.len();
241 println!(
242 "đ Processing {} commits in parallel (concurrency: {})...",
243 total_commits, concurrency
244 );
245
246 let context = if use_contextual {
248 Some(self.collect_context(&full_repo_view).await?)
249 } else {
250 None
251 };
252
253 if let Some(ref ctx) = context {
254 self.show_context_summary(ctx)?;
255 }
256
257 let scope_defs = match &context {
259 Some(ctx) => ctx.project.valid_scopes.clone(),
260 None => self.load_check_scopes(),
261 };
262 for commit in &mut full_repo_view.commits {
263 commit.analysis.refine_scope(&scope_defs);
264 }
265
266 let metadata = claude_client.get_ai_client_metadata();
268 let system_prompt_tokens = if let Some(ref ctx) = context {
269 let prompt_style = metadata.prompt_style();
270 let system_prompt =
271 crate::claude::prompts::generate_contextual_system_prompt_for_provider(
272 ctx,
273 prompt_style,
274 );
275 token_budget::estimate_tokens(&system_prompt)
276 } else {
277 token_budget::estimate_tokens(crate::claude::prompts::SYSTEM_PROMPT)
278 };
279 let batch_plan =
280 batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
281
282 if batch_plan.batches.len() < total_commits {
283 println!(
284 " đĻ Grouped {} commits into {} batches by token budget",
285 total_commits,
286 batch_plan.batches.len()
287 );
288 }
289
290 let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
292 let completed = Arc::new(AtomicUsize::new(0));
293
294 let repo_ref = &full_repo_view;
295 let client_ref = &claude_client;
296 let context_ref = &context;
297 let fresh = self.fresh;
298
299 let futs: Vec<_> = batch_plan
300 .batches
301 .iter()
302 .map(|batch| {
303 let sem = semaphore.clone();
304 let completed = completed.clone();
305 let batch_indices = &batch.commit_indices;
306
307 async move {
308 let _permit = sem
309 .acquire()
310 .await
311 .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
312
313 let batch_size = batch_indices.len();
314
315 let batch_view = if batch_size == 1 {
317 repo_ref.single_commit_view(&repo_ref.commits[batch_indices[0]])
318 } else {
319 let commits: Vec<_> = batch_indices
320 .iter()
321 .map(|&i| &repo_ref.commits[i])
322 .collect();
323 repo_ref.multi_commit_view(&commits)
324 };
325
326 let result = if let Some(ref ctx) = context_ref {
328 client_ref
329 .generate_contextual_amendments_with_options(&batch_view, ctx, fresh)
330 .await
331 } else {
332 client_ref
333 .generate_amendments_with_options(&batch_view, fresh)
334 .await
335 };
336
337 match result {
338 Ok(amendment_file) => {
339 let done =
340 completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
341 println!(" â
{}/{} commits processed", done, total_commits);
342
343 let items: Vec<_> = amendment_file
344 .amendments
345 .into_iter()
346 .map(|a| {
347 let summary = a.summary.clone().unwrap_or_default();
348 (a, summary)
349 })
350 .collect();
351 Ok(items)
352 }
353 Err(e) if batch_size > 1 => {
354 eprintln!(
356 "warning: batch of {} failed, retrying individually: {e}",
357 batch_size
358 );
359 let mut items = Vec::new();
360 for &idx in batch_indices {
361 let single_view =
362 repo_ref.single_commit_view(&repo_ref.commits[idx]);
363 let single_result = if let Some(ref ctx) = context_ref {
364 client_ref
365 .generate_contextual_amendments_with_options(
366 &single_view,
367 ctx,
368 fresh,
369 )
370 .await
371 } else {
372 client_ref
373 .generate_amendments_with_options(&single_view, fresh)
374 .await
375 };
376 match single_result {
377 Ok(af) => {
378 if let Some(a) = af.amendments.into_iter().next() {
379 let summary = a.summary.clone().unwrap_or_default();
380 items.push((a, summary));
381 }
382 }
383 Err(e) => {
384 eprintln!("warning: failed to process commit: {e}");
385 }
386 }
387 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
388 println!(" â
{}/{} commits processed", done, total_commits);
389 }
390 Ok(items)
391 }
392 Err(e) => Err(e),
393 }
394 }
395 })
396 .collect();
397
398 let results = futures::future::join_all(futs).await;
399
400 let mut successes: Vec<(crate::data::amendments::Amendment, String)> = Vec::new();
402 let mut failure_count = 0;
403
404 for result in results {
405 match result {
406 Ok(items) => successes.extend(items),
407 Err(e) => {
408 eprintln!("warning: failed to process commit: {e}");
409 failure_count += 1;
410 }
411 }
412 }
413
414 if failure_count > 0 {
415 eprintln!("warning: {failure_count} commit(s) failed to process");
416 }
417
418 if successes.is_empty() {
419 anyhow::bail!("All commits failed to process");
420 }
421
422 let single_batch = batch_plan.batches.len() <= 1;
425 let all_amendments = if !self.no_coherence && !single_batch && successes.len() >= 2 {
426 println!("đ Running cross-commit coherence pass...");
427 match claude_client.refine_amendments_coherence(&successes).await {
428 Ok(refined) => refined,
429 Err(e) => {
430 eprintln!("warning: coherence pass failed, using individual results: {e}");
431 AmendmentFile {
432 amendments: successes.into_iter().map(|(a, _)| a).collect(),
433 }
434 }
435 }
436 } else {
437 AmendmentFile {
438 amendments: successes.into_iter().map(|(a, _)| a).collect(),
439 }
440 };
441
442 println!(
443 "â
All commits processed! Found {} amendments.",
444 all_amendments.amendments.len()
445 );
446
447 if let Some(save_path) = &self.save_only {
449 all_amendments.save_to_file(save_path)?;
450 println!("đž Amendments saved to file");
451 return Ok(());
452 }
453
454 if !all_amendments.amendments.is_empty() {
456 let temp_dir = tempfile::tempdir()?;
457 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
458 all_amendments.save_to_file(&amendments_file)?;
459
460 if !self.auto_apply
461 && !self.handle_amendments_file(&amendments_file, &all_amendments)?
462 {
463 println!("â Amendment cancelled by user");
464 return Ok(());
465 }
466
467 self.apply_amendments_from_file(&amendments_file).await?;
468 println!("â
Commit messages improved successfully!");
469
470 if self.check {
471 self.run_post_twiddle_check().await?;
472 }
473 } else {
474 println!("⨠No commits found to process!");
475 }
476
477 Ok(())
478 }
479
480 async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
482 use crate::data::{
483 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
484 WorkingDirectoryInfo,
485 };
486 use crate::git::{GitRepository, RemoteInfo};
487 use crate::utils::ai_scratch;
488
489 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
490
491 let repo = GitRepository::open()
493 .context("Failed to open git repository. Make sure you're in a git repository.")?;
494
495 let current_branch = repo
497 .get_current_branch()
498 .unwrap_or_else(|_| "HEAD".to_string());
499
500 let wd_status = repo.get_working_directory_status()?;
502 let working_directory = WorkingDirectoryInfo {
503 clean: wd_status.clean,
504 untracked_changes: wd_status
505 .untracked_changes
506 .into_iter()
507 .map(|fs| FileStatusInfo {
508 status: fs.status,
509 file: fs.file,
510 })
511 .collect(),
512 };
513
514 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
516
517 let commits = repo.get_commits_in_range(commit_range)?;
519
520 let versions = Some(VersionInfo {
522 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
523 });
524
525 let ai_scratch_path =
527 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
528 let ai_info = AiInfo {
529 scratch: ai_scratch_path.to_string_lossy().to_string(),
530 };
531
532 let mut repo_view = RepositoryView {
534 versions,
535 explanation: FieldExplanation::default(),
536 working_directory,
537 remotes,
538 ai: ai_info,
539 branch_info: Some(BranchInfo {
540 branch: current_branch,
541 }),
542 pr_template: None,
543 pr_template_location: None,
544 branch_prs: None,
545 commits,
546 };
547
548 repo_view.update_field_presence();
550
551 Ok(repo_view)
552 }
553
554 fn handle_amendments_file(
556 &self,
557 amendments_file: &std::path::Path,
558 amendments: &crate::data::amendments::AmendmentFile,
559 ) -> Result<bool> {
560 use std::io::{self, Write};
561
562 println!(
563 "\nđ Found {} commits that could be improved.",
564 amendments.amendments.len()
565 );
566 println!("đž Amendments saved to: {}", amendments_file.display());
567 println!();
568
569 loop {
570 print!("â [A]pply amendments, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] ");
571 io::stdout().flush()?;
572
573 let mut input = String::new();
574 io::stdin().read_line(&mut input)?;
575
576 match input.trim().to_lowercase().as_str() {
577 "a" | "apply" | "" => return Ok(true),
578 "s" | "show" => {
579 self.show_amendments_file(amendments_file)?;
580 println!();
581 }
582 "e" | "edit" => {
583 self.edit_amendments_file(amendments_file)?;
584 println!();
585 }
586 "q" | "quit" => return Ok(false),
587 _ => {
588 println!(
589 "Invalid choice. Please enter 'a' to apply, 's' to show, 'e' to edit, or 'q' to quit."
590 );
591 }
592 }
593 }
594 }
595
596 fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
598 use std::fs;
599
600 println!("\nđ Amendments file contents:");
601 println!("âââââââââââââââââââââââââââââ");
602
603 let contents =
604 fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
605
606 println!("{}", contents);
607 println!("âââââââââââââââââââââââââââââ");
608
609 Ok(())
610 }
611
612 fn edit_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
614 use std::env;
615 use std::io::{self, Write};
616 use std::process::Command;
617
618 let editor = env::var("OMNI_DEV_EDITOR")
620 .or_else(|_| env::var("EDITOR"))
621 .unwrap_or_else(|_| {
622 println!(
624 "đ§ Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
625 );
626 print!("Please enter the command to use as your editor: ");
627 io::stdout().flush().expect("Failed to flush stdout");
628
629 let mut input = String::new();
630 io::stdin()
631 .read_line(&mut input)
632 .expect("Failed to read user input");
633 input.trim().to_string()
634 });
635
636 if editor.is_empty() {
637 println!("â No editor specified. Returning to menu.");
638 return Ok(());
639 }
640
641 println!("đ Opening amendments file in editor: {}", editor);
642
643 let mut cmd_parts = editor.split_whitespace();
645 let editor_cmd = cmd_parts.next().unwrap_or(&editor);
646 let args: Vec<&str> = cmd_parts.collect();
647
648 let mut command = Command::new(editor_cmd);
649 command.args(args);
650 command.arg(amendments_file.to_string_lossy().as_ref());
651
652 match command.status() {
653 Ok(status) => {
654 if status.success() {
655 println!("â
Editor session completed.");
656 } else {
657 println!(
658 "â ī¸ Editor exited with non-zero status: {:?}",
659 status.code()
660 );
661 }
662 }
663 Err(e) => {
664 println!("â Failed to execute editor '{}': {}", editor, e);
665 println!(" Please check that the editor command is correct and available in your PATH.");
666 }
667 }
668
669 Ok(())
670 }
671
672 async fn apply_amendments_from_file(&self, amendments_file: &std::path::Path) -> Result<()> {
674 use crate::git::AmendmentHandler;
675
676 let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
678 handler
679 .apply_amendments(&amendments_file.to_string_lossy())
680 .context("Failed to apply amendments")?;
681
682 Ok(())
683 }
684
685 async fn collect_context(
687 &self,
688 repo_view: &crate::data::RepositoryView,
689 ) -> Result<crate::data::context::CommitContext> {
690 use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
691 use crate::data::context::CommitContext;
692
693 let mut context = CommitContext::new();
694
695 let context_dir = self
697 .context_dir
698 .as_ref()
699 .cloned()
700 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
701
702 let repo_root = std::path::PathBuf::from(".");
704 let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
705 debug!(context_dir = ?context_dir, "Using context directory");
706 match discovery.discover() {
707 Ok(project_context) => {
708 debug!("Discovery successful");
709
710 self.show_guidance_files_status(&project_context, &context_dir)?;
712
713 context.project = project_context;
714 }
715 Err(e) => {
716 debug!(error = %e, "Discovery failed");
717 context.project = Default::default();
718 }
719 }
720
721 if let Some(branch_info) = &repo_view.branch_info {
723 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
724 } else {
725 use crate::git::GitRepository;
727 let repo = GitRepository::open()?;
728 let current_branch = repo
729 .get_current_branch()
730 .unwrap_or_else(|_| "HEAD".to_string());
731 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
732 }
733
734 if !repo_view.commits.is_empty() {
736 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
737 }
738
739 if let Some(ref work_ctx) = self.work_context {
741 context.user_provided = Some(work_ctx.clone());
742 }
743
744 if let Some(ref branch_ctx) = self.branch_context {
745 context.branch.description = branch_ctx.clone();
746 }
747
748 Ok(context)
749 }
750
751 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
753 use crate::data::context::{VerbosityLevel, WorkPattern};
754
755 println!("đ Context Analysis:");
756
757 if !context.project.valid_scopes.is_empty() {
759 let scope_names: Vec<&str> = context
760 .project
761 .valid_scopes
762 .iter()
763 .map(|s| s.name.as_str())
764 .collect();
765 println!(" đ Valid scopes: {}", scope_names.join(", "));
766 }
767
768 if context.branch.is_feature_branch {
770 println!(
771 " đŋ Branch: {} ({})",
772 context.branch.description, context.branch.work_type
773 );
774 if let Some(ref ticket) = context.branch.ticket_id {
775 println!(" đĢ Ticket: {}", ticket);
776 }
777 }
778
779 match context.range.work_pattern {
781 WorkPattern::Sequential => println!(" đ Pattern: Sequential development"),
782 WorkPattern::Refactoring => println!(" đ§š Pattern: Refactoring work"),
783 WorkPattern::BugHunt => println!(" đ Pattern: Bug investigation"),
784 WorkPattern::Documentation => println!(" đ Pattern: Documentation updates"),
785 WorkPattern::Configuration => println!(" âī¸ Pattern: Configuration changes"),
786 WorkPattern::Unknown => {}
787 }
788
789 match context.suggested_verbosity() {
791 VerbosityLevel::Comprehensive => {
792 println!(" đ Detail level: Comprehensive (significant changes detected)")
793 }
794 VerbosityLevel::Detailed => println!(" đ Detail level: Detailed"),
795 VerbosityLevel::Concise => println!(" đ Detail level: Concise"),
796 }
797
798 if let Some(ref user_ctx) = context.user_provided {
800 println!(" đ¤ User context: {}", user_ctx);
801 }
802
803 println!();
804 Ok(())
805 }
806
807 fn show_model_info_from_client(
809 &self,
810 client: &crate::claude::client::ClaudeClient,
811 ) -> Result<()> {
812 use crate::claude::model_config::get_model_registry;
813
814 println!("đ¤ AI Model Configuration:");
815
816 let metadata = client.get_ai_client_metadata();
818 let registry = get_model_registry();
819
820 if let Some(spec) = registry.get_model_spec(&metadata.model) {
821 if metadata.model != spec.api_identifier {
823 println!(
824 " đĄ Model: {} â \x1b[33m{}\x1b[0m",
825 metadata.model, spec.api_identifier
826 );
827 } else {
828 println!(" đĄ Model: \x1b[33m{}\x1b[0m", metadata.model);
829 }
830
831 println!(" đˇī¸ Provider: {}", spec.provider);
832 println!(" đ Generation: {}", spec.generation);
833 println!(" â Tier: {} ({})", spec.tier, {
834 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
835 &tier_info.description
836 } else {
837 "No description available"
838 }
839 });
840 println!(" đ¤ Max output tokens: {}", metadata.max_response_length);
841 println!(" đĨ Input context: {}", metadata.max_context_length);
842
843 if let Some((ref key, ref value)) = metadata.active_beta {
844 println!(" đŦ Beta header: {}: {}", key, value);
845 }
846
847 if spec.legacy {
848 println!(" â ī¸ Legacy model (consider upgrading to newer version)");
849 }
850 } else {
851 println!(" đĄ Model: \x1b[33m{}\x1b[0m", metadata.model);
853 println!(" đˇī¸ Provider: {}", metadata.provider);
854 println!(" â ī¸ Model not found in registry, using client metadata:");
855 println!(" đ¤ Max output tokens: {}", metadata.max_response_length);
856 println!(" đĨ Input context: {}", metadata.max_context_length);
857 }
858
859 println!();
860 Ok(())
861 }
862
863 fn show_guidance_files_status(
865 &self,
866 project_context: &crate::data::context::ProjectContext,
867 context_dir: &std::path::Path,
868 ) -> Result<()> {
869 println!("đ Project guidance files status:");
870
871 let guidelines_found = project_context.commit_guidelines.is_some();
873 let guidelines_source = if guidelines_found {
874 let local_path = context_dir.join("local").join("commit-guidelines.md");
875 let project_path = context_dir.join("commit-guidelines.md");
876 let home_path = dirs::home_dir()
877 .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
878 .unwrap_or_default();
879
880 if local_path.exists() {
881 format!("â
Local override: {}", local_path.display())
882 } else if project_path.exists() {
883 format!("â
Project: {}", project_path.display())
884 } else if home_path.exists() {
885 format!("â
Global: {}", home_path.display())
886 } else {
887 "â
(source unknown)".to_string()
888 }
889 } else {
890 "â None found".to_string()
891 };
892 println!(" đ Commit guidelines: {}", guidelines_source);
893
894 let scopes_count = project_context.valid_scopes.len();
896 let scopes_source = if scopes_count > 0 {
897 let local_path = context_dir.join("local").join("scopes.yaml");
898 let project_path = context_dir.join("scopes.yaml");
899 let home_path = dirs::home_dir()
900 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
901 .unwrap_or_default();
902
903 let source = if local_path.exists() {
904 format!("Local override: {}", local_path.display())
905 } else if project_path.exists() {
906 format!("Project: {}", project_path.display())
907 } else if home_path.exists() {
908 format!("Global: {}", home_path.display())
909 } else {
910 "(source unknown + ecosystem defaults)".to_string()
911 };
912 format!("â
{} ({} scopes)", source, scopes_count)
913 } else {
914 "â None found".to_string()
915 };
916 println!(" đ¯ Valid scopes: {}", scopes_source);
917
918 println!();
919 Ok(())
920 }
921
922 async fn execute_no_ai(&self) -> Result<()> {
924 use crate::data::amendments::{Amendment, AmendmentFile};
925
926 println!("đ Generating amendments YAML without AI processing...");
927
928 let repo_view = self.generate_repository_view().await?;
930
931 let amendments: Vec<Amendment> = repo_view
933 .commits
934 .iter()
935 .map(|commit| Amendment {
936 commit: commit.hash.clone(),
937 message: commit.original_message.clone(),
938 summary: None,
939 })
940 .collect();
941
942 let amendment_file = AmendmentFile { amendments };
943
944 if let Some(save_path) = &self.save_only {
946 amendment_file.save_to_file(save_path)?;
947 println!("đž Amendments saved to file");
948 return Ok(());
949 }
950
951 if !amendment_file.amendments.is_empty() {
953 let temp_dir = tempfile::tempdir()?;
955 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
956 amendment_file.save_to_file(&amendments_file)?;
957
958 if !self.auto_apply
960 && !self.handle_amendments_file(&amendments_file, &amendment_file)?
961 {
962 println!("â Amendment cancelled by user");
963 return Ok(());
964 }
965
966 self.apply_amendments_from_file(&amendments_file).await?;
968 println!("â
Commit messages applied successfully!");
969
970 if self.check {
972 self.run_post_twiddle_check().await?;
973 }
974 } else {
975 println!("⨠No commits found to process!");
976 }
977
978 Ok(())
979 }
980
981 async fn run_post_twiddle_check(&self) -> Result<()> {
985 use crate::data::amendments::AmendmentFile;
986
987 const MAX_CHECK_RETRIES: u32 = 3;
988
989 let guidelines = self.load_check_guidelines()?;
991 let valid_scopes = self.load_check_scopes();
992 let beta = self
993 .beta_header
994 .as_deref()
995 .map(parse_beta_header)
996 .transpose()?;
997 let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
998
999 for attempt in 0..=MAX_CHECK_RETRIES {
1000 println!();
1001 if attempt == 0 {
1002 println!("đ Running commit message validation...");
1003 } else {
1004 println!(
1005 "đ Re-checking commit messages (retry {}/{})...",
1006 attempt, MAX_CHECK_RETRIES
1007 );
1008 }
1009
1010 let mut repo_view = self.generate_repository_view().await?;
1012
1013 if repo_view.commits.is_empty() {
1014 println!("â ī¸ No commits to check");
1015 return Ok(());
1016 }
1017
1018 println!("đ Checking {} commits", repo_view.commits.len());
1019
1020 for commit in &mut repo_view.commits {
1022 commit.analysis.refine_scope(&valid_scopes);
1023 }
1024
1025 if attempt == 0 {
1026 self.show_check_guidance_files_status(&guidelines, &valid_scopes);
1027 }
1028
1029 let report = if repo_view.commits.len() > 1 {
1031 println!(
1032 "đ Checking {} commits in parallel...",
1033 repo_view.commits.len()
1034 );
1035 self.check_commits_map_reduce(
1036 &claude_client,
1037 &repo_view,
1038 guidelines.as_deref(),
1039 &valid_scopes,
1040 )
1041 .await?
1042 } else {
1043 println!("đ¤ Analyzing commits with AI...");
1044 claude_client
1045 .check_commits_with_scopes(
1046 &repo_view,
1047 guidelines.as_deref(),
1048 &valid_scopes,
1049 true,
1050 )
1051 .await?
1052 };
1053
1054 self.output_check_text_report(&report)?;
1056
1057 if !report.has_errors() {
1059 if report.has_warnings() {
1060 println!("âšī¸ Some commit messages have minor warnings");
1061 } else {
1062 println!("â
All commit messages pass validation");
1063 }
1064 return Ok(());
1065 }
1066
1067 if attempt == MAX_CHECK_RETRIES {
1069 println!(
1070 "â ī¸ Some commit messages still have issues after {} retries",
1071 MAX_CHECK_RETRIES
1072 );
1073 return Ok(());
1074 }
1075
1076 let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
1078
1079 if amendments.is_empty() {
1080 println!(
1081 "â ī¸ Some commit messages have issues but no suggestions available to retry"
1082 );
1083 return Ok(());
1084 }
1085
1086 println!(
1088 "đ Applying {} suggested fix(es) and re-checking...",
1089 amendments.len()
1090 );
1091 let amendment_file = AmendmentFile { amendments };
1092 let temp_file = tempfile::NamedTempFile::new()
1093 .context("Failed to create temp file for retry amendments")?;
1094 amendment_file
1095 .save_to_file(temp_file.path())
1096 .context("Failed to save retry amendments")?;
1097 self.apply_amendments_from_file(temp_file.path()).await?;
1098 }
1099
1100 Ok(())
1101 }
1102
1103 fn build_amendments_from_suggestions(
1107 &self,
1108 report: &crate::data::check::CheckReport,
1109 repo_view: &crate::data::RepositoryView,
1110 ) -> Vec<crate::data::amendments::Amendment> {
1111 use crate::data::amendments::Amendment;
1112
1113 report
1114 .commits
1115 .iter()
1116 .filter(|r| !r.passes)
1117 .filter_map(|r| {
1118 let suggestion = r.suggestion.as_ref()?;
1119 let full_hash = repo_view.commits.iter().find_map(|c| {
1121 if c.hash.starts_with(&r.hash) || r.hash.starts_with(&c.hash) {
1122 Some(c.hash.clone())
1123 } else {
1124 None
1125 }
1126 });
1127 full_hash.map(|hash| Amendment::new(hash, suggestion.message.clone()))
1128 })
1129 .collect()
1130 }
1131
1132 fn load_check_guidelines(&self) -> Result<Option<String>> {
1134 use std::fs;
1135
1136 let context_dir = self
1137 .context_dir
1138 .clone()
1139 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
1140
1141 let local_path = context_dir.join("local").join("commit-guidelines.md");
1143 if local_path.exists() {
1144 let content = fs::read_to_string(&local_path)
1145 .with_context(|| format!("Failed to read guidelines: {:?}", local_path))?;
1146 return Ok(Some(content));
1147 }
1148
1149 let project_path = context_dir.join("commit-guidelines.md");
1151 if project_path.exists() {
1152 let content = fs::read_to_string(&project_path)
1153 .with_context(|| format!("Failed to read guidelines: {:?}", project_path))?;
1154 return Ok(Some(content));
1155 }
1156
1157 if let Some(home) = dirs::home_dir() {
1159 let home_path = home.join(".omni-dev").join("commit-guidelines.md");
1160 if home_path.exists() {
1161 let content = fs::read_to_string(&home_path)
1162 .with_context(|| format!("Failed to read guidelines: {:?}", home_path))?;
1163 return Ok(Some(content));
1164 }
1165 }
1166
1167 Ok(None)
1168 }
1169
1170 fn load_check_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
1172 let context_dir = self
1173 .context_dir
1174 .clone()
1175 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
1176 crate::claude::context::load_project_scopes(&context_dir, &std::path::PathBuf::from("."))
1177 }
1178
1179 fn show_check_guidance_files_status(
1181 &self,
1182 guidelines: &Option<String>,
1183 valid_scopes: &[crate::data::context::ScopeDefinition],
1184 ) {
1185 let context_dir = self
1186 .context_dir
1187 .clone()
1188 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
1189
1190 println!("đ Project guidance files status:");
1191
1192 let guidelines_found = guidelines.is_some();
1194 let guidelines_source = if guidelines_found {
1195 let local_path = context_dir.join("local").join("commit-guidelines.md");
1196 let project_path = context_dir.join("commit-guidelines.md");
1197 let home_path = dirs::home_dir()
1198 .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
1199 .unwrap_or_default();
1200
1201 if local_path.exists() {
1202 format!("â
Local override: {}", local_path.display())
1203 } else if project_path.exists() {
1204 format!("â
Project: {}", project_path.display())
1205 } else if home_path.exists() {
1206 format!("â
Global: {}", home_path.display())
1207 } else {
1208 "â
(source unknown)".to_string()
1209 }
1210 } else {
1211 "âĒ Using defaults".to_string()
1212 };
1213 println!(" đ Commit guidelines: {}", guidelines_source);
1214
1215 let scopes_count = valid_scopes.len();
1217 let scopes_source = if scopes_count > 0 {
1218 let local_path = context_dir.join("local").join("scopes.yaml");
1219 let project_path = context_dir.join("scopes.yaml");
1220 let home_path = dirs::home_dir()
1221 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
1222 .unwrap_or_default();
1223
1224 let source = if local_path.exists() {
1225 format!("Local override: {}", local_path.display())
1226 } else if project_path.exists() {
1227 format!("Project: {}", project_path.display())
1228 } else if home_path.exists() {
1229 format!("Global: {}", home_path.display())
1230 } else {
1231 "(source unknown)".to_string()
1232 };
1233 format!("â
{} ({} scopes)", source, scopes_count)
1234 } else {
1235 "âĒ None found (any scope accepted)".to_string()
1236 };
1237 println!(" đ¯ Valid scopes: {}", scopes_source);
1238
1239 println!();
1240 }
1241
1242 async fn check_commits_map_reduce(
1244 &self,
1245 claude_client: &crate::claude::client::ClaudeClient,
1246 full_repo_view: &crate::data::RepositoryView,
1247 guidelines: Option<&str>,
1248 valid_scopes: &[crate::data::context::ScopeDefinition],
1249 ) -> Result<crate::data::check::CheckReport> {
1250 use std::sync::atomic::{AtomicUsize, Ordering};
1251 use std::sync::Arc;
1252
1253 use crate::claude::batch;
1254 use crate::claude::token_budget;
1255 use crate::data::check::{CheckReport, CommitCheckResult};
1256
1257 let total_commits = full_repo_view.commits.len();
1258
1259 let metadata = claude_client.get_ai_client_metadata();
1261 let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
1262 guidelines,
1263 valid_scopes,
1264 );
1265 let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
1266 let batch_plan =
1267 batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
1268
1269 if batch_plan.batches.len() < total_commits {
1270 println!(
1271 " đĻ Grouped {} commits into {} batches by token budget",
1272 total_commits,
1273 batch_plan.batches.len()
1274 );
1275 }
1276
1277 let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
1278 let completed = Arc::new(AtomicUsize::new(0));
1279
1280 let futs: Vec<_> = batch_plan
1281 .batches
1282 .iter()
1283 .map(|batch| {
1284 let sem = semaphore.clone();
1285 let completed = completed.clone();
1286 let batch_indices = &batch.commit_indices;
1287
1288 async move {
1289 let _permit = sem
1290 .acquire()
1291 .await
1292 .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
1293
1294 let batch_size = batch_indices.len();
1295
1296 let batch_view = if batch_size == 1 {
1297 full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
1298 } else {
1299 let commits: Vec<_> = batch_indices
1300 .iter()
1301 .map(|&i| &full_repo_view.commits[i])
1302 .collect();
1303 full_repo_view.multi_commit_view(&commits)
1304 };
1305
1306 let result = claude_client
1307 .check_commits_with_scopes(&batch_view, guidelines, valid_scopes, true)
1308 .await;
1309
1310 match result {
1311 Ok(report) => {
1312 let done =
1313 completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
1314 println!(" â
{}/{} commits checked", done, total_commits);
1315
1316 let items: Vec<_> = report
1317 .commits
1318 .into_iter()
1319 .map(|r| {
1320 let summary = r.summary.clone().unwrap_or_default();
1321 (r, summary)
1322 })
1323 .collect();
1324 Ok(items)
1325 }
1326 Err(e) if batch_size > 1 => {
1327 eprintln!(
1328 "warning: batch of {} failed, retrying individually: {e}",
1329 batch_size
1330 );
1331 let mut items = Vec::new();
1332 for &idx in batch_indices {
1333 let single_view =
1334 full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1335 let single_result = claude_client
1336 .check_commits_with_scopes(
1337 &single_view,
1338 guidelines,
1339 valid_scopes,
1340 true,
1341 )
1342 .await;
1343 match single_result {
1344 Ok(report) => {
1345 if let Some(r) = report.commits.into_iter().next() {
1346 let summary = r.summary.clone().unwrap_or_default();
1347 items.push((r, summary));
1348 }
1349 }
1350 Err(e) => {
1351 eprintln!("warning: failed to check commit: {e}");
1352 }
1353 }
1354 let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
1355 println!(" â
{}/{} commits checked", done, total_commits);
1356 }
1357 Ok(items)
1358 }
1359 Err(e) => Err(e),
1360 }
1361 }
1362 })
1363 .collect();
1364
1365 let results = futures::future::join_all(futs).await;
1366
1367 let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
1368 let mut failure_count = 0;
1369
1370 for result in results {
1371 match result {
1372 Ok(items) => successes.extend(items),
1373 Err(e) => {
1374 eprintln!("warning: failed to check commit: {e}");
1375 failure_count += 1;
1376 }
1377 }
1378 }
1379
1380 if failure_count > 0 {
1381 eprintln!("warning: {failure_count} commit(s) failed to check");
1382 }
1383
1384 if successes.is_empty() {
1385 anyhow::bail!("All commits failed to check");
1386 }
1387
1388 let single_batch = batch_plan.batches.len() <= 1;
1390 if !self.no_coherence && !single_batch && successes.len() >= 2 {
1391 println!("đ Running cross-commit coherence pass...");
1392 match claude_client
1393 .refine_checks_coherence(&successes, full_repo_view)
1394 .await
1395 {
1396 Ok(refined) => return Ok(refined),
1397 Err(e) => {
1398 eprintln!("warning: coherence pass failed, using individual results: {e}");
1399 }
1400 }
1401 }
1402
1403 Ok(CheckReport::new(
1404 successes.into_iter().map(|(r, _)| r).collect(),
1405 ))
1406 }
1407
1408 fn output_check_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
1410 use crate::data::check::IssueSeverity;
1411
1412 println!();
1413
1414 for result in &report.commits {
1415 if result.passes {
1417 continue;
1418 }
1419
1420 let icon = if result
1422 .issues
1423 .iter()
1424 .any(|i| i.severity == IssueSeverity::Error)
1425 {
1426 "â"
1427 } else {
1428 "â ī¸ "
1429 };
1430
1431 let short_hash = if result.hash.len() > 7 {
1433 &result.hash[..7]
1434 } else {
1435 &result.hash
1436 };
1437
1438 println!("{} {} - \"{}\"", icon, short_hash, result.message);
1439
1440 for issue in &result.issues {
1442 let severity_str = match issue.severity {
1443 IssueSeverity::Error => "\x1b[31mERROR\x1b[0m ",
1444 IssueSeverity::Warning => "\x1b[33mWARNING\x1b[0m",
1445 IssueSeverity::Info => "\x1b[36mINFO\x1b[0m ",
1446 };
1447
1448 println!(
1449 " {} [{}] {}",
1450 severity_str, issue.section, issue.explanation
1451 );
1452 }
1453
1454 if let Some(suggestion) = &result.suggestion {
1456 println!();
1457 println!(" Suggested message:");
1458 for line in suggestion.message.lines() {
1459 println!(" {}", line);
1460 }
1461 }
1462
1463 println!();
1464 }
1465
1466 println!("ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ");
1468 println!("Summary: {} commits checked", report.summary.total_commits);
1469 println!(
1470 " {} errors, {} warnings",
1471 report.summary.error_count, report.summary.warning_count
1472 );
1473 println!(
1474 " {} passed, {} with issues",
1475 report.summary.passing_commits, report.summary.failing_commits
1476 );
1477
1478 Ok(())
1479 }
1480}