Skip to main content

omni_dev/cli/git/
twiddle.rs

1//! Twiddle command — AI-powered commit message improvement.
2
3use anyhow::{Context, Result};
4use clap::Parser;
5use tracing::debug;
6
7use super::parse_beta_header;
8
9/// Twiddle command options.
10#[derive(Parser)]
11pub struct TwiddleCommand {
12    /// Commit range to analyze and improve (e.g., HEAD~3..HEAD, abc123..def456).
13    #[arg(value_name = "COMMIT_RANGE")]
14    pub commit_range: Option<String>,
15
16    /// Claude API model to use (if not specified, uses settings or default).
17    #[arg(long)]
18    pub model: Option<String>,
19
20    /// Beta header to send with API requests (format: key:value).
21    /// Only sent if the model supports it in the registry.
22    #[arg(long, value_name = "KEY:VALUE")]
23    pub beta_header: Option<String>,
24
25    /// Skips confirmation prompt and applies amendments automatically.
26    #[arg(long)]
27    pub auto_apply: bool,
28
29    /// Saves generated amendments to file without applying.
30    #[arg(long, value_name = "FILE")]
31    pub save_only: Option<String>,
32
33    /// Uses additional project context for better suggestions (Phase 3).
34    #[arg(long, default_value = "true")]
35    pub use_context: bool,
36
37    /// Path to custom context directory (defaults to .omni-dev/).
38    #[arg(long)]
39    pub context_dir: Option<std::path::PathBuf>,
40
41    /// Specifies work context (e.g., "feature: user authentication").
42    #[arg(long)]
43    pub work_context: Option<String>,
44
45    /// Overrides detected branch context.
46    #[arg(long)]
47    pub branch_context: Option<String>,
48
49    /// Disables contextual analysis (uses basic prompting only).
50    #[arg(long)]
51    pub no_context: bool,
52
53    /// Deprecated: use --concurrency instead.
54    #[arg(long, default_value = "4", hide = true)]
55    pub batch_size: usize,
56
57    /// Maximum number of concurrent AI requests (default: 4).
58    #[arg(long, default_value = "4")]
59    pub concurrency: usize,
60
61    /// Disables the cross-commit coherence pass.
62    #[arg(long)]
63    pub no_coherence: bool,
64
65    /// Skips AI processing and only outputs repository YAML.
66    #[arg(long)]
67    pub no_ai: bool,
68
69    /// Ignores existing commit messages and generates fresh ones based solely on diffs.
70    #[arg(long)]
71    pub fresh: bool,
72
73    /// Runs commit message validation after applying amendments.
74    #[arg(long)]
75    pub check: bool,
76}
77
78impl TwiddleCommand {
79    /// Executes the twiddle command with contextual intelligence.
80    pub async fn execute(self) -> Result<()> {
81        // If --no-ai flag is set, skip AI processing and output YAML directly
82        if self.no_ai {
83            return self.execute_no_ai().await;
84        }
85
86        // Preflight check: validate AI credentials before any processing
87        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        // Preflight check: ensure working directory is clean before expensive operations
94        crate::utils::preflight::check_working_directory_clean()?;
95        println!("✓ Working directory is clean");
96
97        // Determine if contextual analysis should be used
98        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        // 1. Generate repository view to get all commits
109        let mut full_repo_view = self.generate_repository_view().await?;
110
111        // 2. Use parallel map-reduce for multiple commits
112        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        // 3. Collect contextual information (Phase 3)
119        let context = if use_contextual {
120            Some(self.collect_context(&full_repo_view).await?)
121        } else {
122            None
123        };
124
125        // Refine detected scopes using file_patterns from scope definitions
126        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        // 4. Show context summary if available
135        if let Some(ref ctx) = context {
136            self.show_context_summary(ctx)?;
137        }
138
139        // 5. Initialize Claude client
140        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        // Show model information
148        self.show_model_info_from_client(&claude_client)?;
149
150        // 6. Generate amendments via Claude API with context
151        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        // 6. Handle different output modes
171        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        // 7. Handle amendments
178        if !amendments.amendments.is_empty() {
179            // Create temporary file for amendments
180            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            // Show file path and get user choice
185            if !self.auto_apply && !self.handle_amendments_file(&amendments_file, &amendments)? {
186                println!("❌ Amendment cancelled by user");
187                return Ok(());
188            }
189
190            // 8. Apply amendments (re-read from file to capture any user edits)
191            self.apply_amendments_from_file(&amendments_file).await?;
192            println!("✅ Commit messages improved successfully!");
193
194            // 9. Run post-twiddle check if --check flag is set
195            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    /// Executes the twiddle command with batched parallel map-reduce for multiple commits.
206    ///
207    /// Commits are grouped into token-budget-aware batches (map phase),
208    /// then an optional coherence pass refines results across all commits
209    /// (reduce phase). Coherence is skipped when all commits fit in a
210    /// single batch since the AI already saw them together.
211    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        // Initialize Claude client
226        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        // Show model information
234        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        // Collect context once (shared across all commits)
247        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        // Refine scopes on all commits upfront
258        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        // Plan batches based on token budget
267        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        // Map phase: process batches in parallel
291        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                    // Create view for this batch
316                    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                    // Generate amendments for the batch
327                    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                            // Split-and-retry: fall back to individual commits
355                            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        // Flatten batch results
401        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        // Reduce phase: optional coherence pass
423        // Skip when all commits were in a single batch (AI already saw them together)
424        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        // Handle different output modes
448        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        // Handle amendments
455        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    /// Generates the repository view (reuses ViewCommand logic).
481    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        // Open git repository
492        let repo = GitRepository::open()
493            .context("Failed to open git repository. Make sure you're in a git repository.")?;
494
495        // Get current branch name
496        let current_branch = repo
497            .get_current_branch()
498            .unwrap_or_else(|_| "HEAD".to_string());
499
500        // Get working directory status
501        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        // Get remote information
515        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
516
517        // Parse commit range and get commits
518        let commits = repo.get_commits_in_range(commit_range)?;
519
520        // Create version information
521        let versions = Some(VersionInfo {
522            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
523        });
524
525        // Get AI scratch directory
526        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        // Build repository view with branch info
533        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        // Update field presence based on actual data
549        repo_view.update_field_presence();
550
551        Ok(repo_view)
552    }
553
554    /// Handles the amendments file by showing the path and getting the user choice.
555    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    /// Shows the contents of the amendments file.
597    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    /// Opens the amendments file in an external editor.
613    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        // Try to get editor from environment variables
619        let editor = env::var("OMNI_DEV_EDITOR")
620            .or_else(|_| env::var("EDITOR"))
621            .unwrap_or_else(|_| {
622                // Prompt user for editor if neither environment variable is set
623                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        // Split editor command to handle arguments
644        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    /// Applies amendments from a file path (re-reads from disk to capture user edits).
673    async fn apply_amendments_from_file(&self, amendments_file: &std::path::Path) -> Result<()> {
674        use crate::git::AmendmentHandler;
675
676        // Use AmendmentHandler to apply amendments directly from file
677        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    /// Collects contextual information for enhanced commit message generation.
686    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        // 1. Discover project context
696        let context_dir = self
697            .context_dir
698            .as_ref()
699            .cloned()
700            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
701
702        // ProjectDiscovery takes repo root and context directory
703        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                // Show diagnostic information about loaded guidance files
711                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        // 2. Analyze current branch from repository view
722        if let Some(branch_info) = &repo_view.branch_info {
723            context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
724        } else {
725            // Fallback to getting current branch directly if not in repo view
726            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(&current_branch).unwrap_or_default();
732        }
733
734        // 3. Analyze commit range patterns
735        if !repo_view.commits.is_empty() {
736            context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
737        }
738
739        // 4. Apply user-provided context overrides
740        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    /// Shows the context summary to the user.
752    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        // Project context
758        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        // Branch context
769        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        // Work pattern
780        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        // Verbosity level
790        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        // User context
799        if let Some(ref user_ctx) = context.user_provided {
800            println!("   👤 User context: {}", user_ctx);
801        }
802
803        println!();
804        Ok(())
805    }
806
807    /// Shows model information from the actual AI client.
808    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        // Get actual metadata from the client
817        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            // Highlight the API identifier portion in yellow
822            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            // Fallback to client metadata if not in registry
852            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    /// Shows diagnostic information about loaded guidance files.
864    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        // Check commit guidelines
872        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        // Check scopes
895        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    /// Executes the twiddle command without AI, creating amendments with original messages.
923    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        // Generate repository view to get all commits
929        let repo_view = self.generate_repository_view().await?;
930
931        // Create amendments with original commit messages (no AI improvements)
932        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        // Handle different output modes
945        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        // Handle amendments using the same flow as the AI-powered version
952        if !amendment_file.amendments.is_empty() {
953            // Create temporary file for amendments
954            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            // Show file path and get user choice
959            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            // Apply amendments (re-read from file to capture any user edits)
967            self.apply_amendments_from_file(&amendments_file).await?;
968            println!("✅ Commit messages applied successfully!");
969
970            // Run post-twiddle check if --check flag is set
971            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    /// Runs commit message validation after twiddle amendments are applied.
982    /// If the check finds errors with suggestions, automatically applies the
983    /// suggestions and re-checks, up to 3 retries.
984    async fn run_post_twiddle_check(&self) -> Result<()> {
985        use crate::data::amendments::AmendmentFile;
986
987        const MAX_CHECK_RETRIES: u32 = 3;
988
989        // Load guidelines, scopes, and Claude client once (they don't change between retries)
990        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            // Generate fresh repository view to get updated commit messages
1011            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            // Refine detected scopes using file_patterns from scope definitions
1021            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            // Run check
1030            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            // Output text report
1055            self.output_check_text_report(&report)?;
1056
1057            // If no errors, we're done
1058            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 we've exhausted retries, report and stop
1068            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            // Build amendments from suggestions for failing commits
1077            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            // Apply the suggested amendments
1087            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    /// Builds amendments from check report suggestions for failing commits.
1104    /// Resolves short hashes from the AI response to full 40-char hashes
1105    /// from the repository view.
1106    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                // Resolve short hash to full 40-char hash
1120                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    /// Loads commit guidelines for check (mirrors `CheckCommand::load_guidelines`).
1133    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        // Try local override first
1142        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        // Try project-level guidelines
1150        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        // Try global guidelines
1158        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    /// Loads valid scopes for check (mirrors `CheckCommand::load_scopes`).
1171    fn load_check_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
1172        use crate::data::context::ScopeDefinition;
1173        use std::fs;
1174
1175        #[derive(serde::Deserialize)]
1176        struct ScopesConfig {
1177            scopes: Vec<ScopeDefinition>,
1178        }
1179
1180        let context_dir = self
1181            .context_dir
1182            .clone()
1183            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
1184
1185        // Search paths in priority order: local override → project-level → global
1186        let mut candidates: Vec<std::path::PathBuf> = vec![
1187            context_dir.join("local").join("scopes.yaml"),
1188            context_dir.join("scopes.yaml"),
1189        ];
1190        if let Some(home) = dirs::home_dir() {
1191            candidates.push(home.join(".omni-dev").join("scopes.yaml"));
1192        }
1193
1194        for path in &candidates {
1195            if !path.exists() {
1196                continue;
1197            }
1198            match fs::read_to_string(path) {
1199                Ok(content) => match serde_yaml::from_str::<ScopesConfig>(&content) {
1200                    Ok(config) => return config.scopes,
1201                    Err(e) => {
1202                        eprintln!(
1203                            "warning: ignoring malformed scopes file {}: {e}",
1204                            path.display()
1205                        );
1206                    }
1207                },
1208                Err(e) => {
1209                    eprintln!("warning: cannot read scopes file {}: {e}", path.display());
1210                }
1211            }
1212        }
1213
1214        Vec::new()
1215    }
1216
1217    /// Shows guidance files status for check.
1218    fn show_check_guidance_files_status(
1219        &self,
1220        guidelines: &Option<String>,
1221        valid_scopes: &[crate::data::context::ScopeDefinition],
1222    ) {
1223        let context_dir = self
1224            .context_dir
1225            .clone()
1226            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
1227
1228        println!("📋 Project guidance files status:");
1229
1230        // Check commit guidelines
1231        let guidelines_found = guidelines.is_some();
1232        let guidelines_source = if guidelines_found {
1233            let local_path = context_dir.join("local").join("commit-guidelines.md");
1234            let project_path = context_dir.join("commit-guidelines.md");
1235            let home_path = dirs::home_dir()
1236                .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
1237                .unwrap_or_default();
1238
1239            if local_path.exists() {
1240                format!("✅ Local override: {}", local_path.display())
1241            } else if project_path.exists() {
1242                format!("✅ Project: {}", project_path.display())
1243            } else if home_path.exists() {
1244                format!("✅ Global: {}", home_path.display())
1245            } else {
1246                "✅ (source unknown)".to_string()
1247            }
1248        } else {
1249            "âšĒ Using defaults".to_string()
1250        };
1251        println!("   📝 Commit guidelines: {}", guidelines_source);
1252
1253        // Check scopes
1254        let scopes_count = valid_scopes.len();
1255        let scopes_source = if scopes_count > 0 {
1256            let local_path = context_dir.join("local").join("scopes.yaml");
1257            let project_path = context_dir.join("scopes.yaml");
1258            let home_path = dirs::home_dir()
1259                .map(|h| h.join(".omni-dev").join("scopes.yaml"))
1260                .unwrap_or_default();
1261
1262            let source = if local_path.exists() {
1263                format!("Local override: {}", local_path.display())
1264            } else if project_path.exists() {
1265                format!("Project: {}", project_path.display())
1266            } else if home_path.exists() {
1267                format!("Global: {}", home_path.display())
1268            } else {
1269                "(source unknown)".to_string()
1270            };
1271            format!("✅ {} ({} scopes)", source, scopes_count)
1272        } else {
1273            "âšĒ None found (any scope accepted)".to_string()
1274        };
1275        println!("   đŸŽ¯ Valid scopes: {}", scopes_source);
1276
1277        println!();
1278    }
1279
1280    /// Checks commits using batched parallel map-reduce.
1281    async fn check_commits_map_reduce(
1282        &self,
1283        claude_client: &crate::claude::client::ClaudeClient,
1284        full_repo_view: &crate::data::RepositoryView,
1285        guidelines: Option<&str>,
1286        valid_scopes: &[crate::data::context::ScopeDefinition],
1287    ) -> Result<crate::data::check::CheckReport> {
1288        use std::sync::atomic::{AtomicUsize, Ordering};
1289        use std::sync::Arc;
1290
1291        use crate::claude::batch;
1292        use crate::claude::token_budget;
1293        use crate::data::check::{CheckReport, CommitCheckResult};
1294
1295        let total_commits = full_repo_view.commits.len();
1296
1297        // Plan batches based on token budget
1298        let metadata = claude_client.get_ai_client_metadata();
1299        let system_prompt = crate::claude::prompts::generate_check_system_prompt_with_scopes(
1300            guidelines,
1301            valid_scopes,
1302        );
1303        let system_prompt_tokens = token_budget::estimate_tokens(&system_prompt);
1304        let batch_plan =
1305            batch::plan_batches(&full_repo_view.commits, &metadata, system_prompt_tokens);
1306
1307        if batch_plan.batches.len() < total_commits {
1308            println!(
1309                "   đŸ“Ļ Grouped {} commits into {} batches by token budget",
1310                total_commits,
1311                batch_plan.batches.len()
1312            );
1313        }
1314
1315        let semaphore = Arc::new(tokio::sync::Semaphore::new(self.concurrency));
1316        let completed = Arc::new(AtomicUsize::new(0));
1317
1318        let futs: Vec<_> = batch_plan
1319            .batches
1320            .iter()
1321            .map(|batch| {
1322                let sem = semaphore.clone();
1323                let completed = completed.clone();
1324                let batch_indices = &batch.commit_indices;
1325
1326                async move {
1327                    let _permit = sem
1328                        .acquire()
1329                        .await
1330                        .map_err(|e| anyhow::anyhow!("semaphore closed: {e}"))?;
1331
1332                    let batch_size = batch_indices.len();
1333
1334                    let batch_view = if batch_size == 1 {
1335                        full_repo_view.single_commit_view(&full_repo_view.commits[batch_indices[0]])
1336                    } else {
1337                        let commits: Vec<_> = batch_indices
1338                            .iter()
1339                            .map(|&i| &full_repo_view.commits[i])
1340                            .collect();
1341                        full_repo_view.multi_commit_view(&commits)
1342                    };
1343
1344                    let result = claude_client
1345                        .check_commits_with_scopes(&batch_view, guidelines, valid_scopes, true)
1346                        .await;
1347
1348                    match result {
1349                        Ok(report) => {
1350                            let done =
1351                                completed.fetch_add(batch_size, Ordering::Relaxed) + batch_size;
1352                            println!("   ✅ {}/{} commits checked", done, total_commits);
1353
1354                            let items: Vec<_> = report
1355                                .commits
1356                                .into_iter()
1357                                .map(|r| {
1358                                    let summary = r.summary.clone().unwrap_or_default();
1359                                    (r, summary)
1360                                })
1361                                .collect();
1362                            Ok(items)
1363                        }
1364                        Err(e) if batch_size > 1 => {
1365                            eprintln!(
1366                                "warning: batch of {} failed, retrying individually: {e}",
1367                                batch_size
1368                            );
1369                            let mut items = Vec::new();
1370                            for &idx in batch_indices {
1371                                let single_view =
1372                                    full_repo_view.single_commit_view(&full_repo_view.commits[idx]);
1373                                let single_result = claude_client
1374                                    .check_commits_with_scopes(
1375                                        &single_view,
1376                                        guidelines,
1377                                        valid_scopes,
1378                                        true,
1379                                    )
1380                                    .await;
1381                                match single_result {
1382                                    Ok(report) => {
1383                                        if let Some(r) = report.commits.into_iter().next() {
1384                                            let summary = r.summary.clone().unwrap_or_default();
1385                                            items.push((r, summary));
1386                                        }
1387                                    }
1388                                    Err(e) => {
1389                                        eprintln!("warning: failed to check commit: {e}");
1390                                    }
1391                                }
1392                                let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
1393                                println!("   ✅ {}/{} commits checked", done, total_commits);
1394                            }
1395                            Ok(items)
1396                        }
1397                        Err(e) => Err(e),
1398                    }
1399                }
1400            })
1401            .collect();
1402
1403        let results = futures::future::join_all(futs).await;
1404
1405        let mut successes: Vec<(CommitCheckResult, String)> = Vec::new();
1406        let mut failure_count = 0;
1407
1408        for result in results {
1409            match result {
1410                Ok(items) => successes.extend(items),
1411                Err(e) => {
1412                    eprintln!("warning: failed to check commit: {e}");
1413                    failure_count += 1;
1414                }
1415            }
1416        }
1417
1418        if failure_count > 0 {
1419            eprintln!("warning: {failure_count} commit(s) failed to check");
1420        }
1421
1422        if successes.is_empty() {
1423            anyhow::bail!("All commits failed to check");
1424        }
1425
1426        // Coherence pass: skip when all commits were in a single batch
1427        let single_batch = batch_plan.batches.len() <= 1;
1428        if !self.no_coherence && !single_batch && successes.len() >= 2 {
1429            println!("🔗 Running cross-commit coherence pass...");
1430            match claude_client
1431                .refine_checks_coherence(&successes, full_repo_view)
1432                .await
1433            {
1434                Ok(refined) => return Ok(refined),
1435                Err(e) => {
1436                    eprintln!("warning: coherence pass failed, using individual results: {e}");
1437                }
1438            }
1439        }
1440
1441        Ok(CheckReport::new(
1442            successes.into_iter().map(|(r, _)| r).collect(),
1443        ))
1444    }
1445
1446    /// Outputs the text format check report (mirrors `CheckCommand::output_text_report`).
1447    fn output_check_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
1448        use crate::data::check::IssueSeverity;
1449
1450        println!();
1451
1452        for result in &report.commits {
1453            // Skip passing commits
1454            if result.passes {
1455                continue;
1456            }
1457
1458            // Determine icon
1459            let icon = if result
1460                .issues
1461                .iter()
1462                .any(|i| i.severity == IssueSeverity::Error)
1463            {
1464                "❌"
1465            } else {
1466                "âš ī¸ "
1467            };
1468
1469            // Short hash
1470            let short_hash = if result.hash.len() > 7 {
1471                &result.hash[..7]
1472            } else {
1473                &result.hash
1474            };
1475
1476            println!("{} {} - \"{}\"", icon, short_hash, result.message);
1477
1478            // Print issues
1479            for issue in &result.issues {
1480                let severity_str = match issue.severity {
1481                    IssueSeverity::Error => "\x1b[31mERROR\x1b[0m  ",
1482                    IssueSeverity::Warning => "\x1b[33mWARNING\x1b[0m",
1483                    IssueSeverity::Info => "\x1b[36mINFO\x1b[0m   ",
1484                };
1485
1486                println!(
1487                    "   {} [{}] {}",
1488                    severity_str, issue.section, issue.explanation
1489                );
1490            }
1491
1492            // Print suggestion if available
1493            if let Some(suggestion) = &result.suggestion {
1494                println!();
1495                println!("   Suggested message:");
1496                for line in suggestion.message.lines() {
1497                    println!("      {}", line);
1498                }
1499            }
1500
1501            println!();
1502        }
1503
1504        // Print summary
1505        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1506        println!("Summary: {} commits checked", report.summary.total_commits);
1507        println!(
1508            "  {} errors, {} warnings",
1509            report.summary.error_count, report.summary.warning_count
1510        );
1511        println!(
1512            "  {} passed, {} with issues",
1513            report.summary.passing_commits, report.summary.failing_commits
1514        );
1515
1516        Ok(())
1517    }
1518}