Skip to main content

omni_dev/cli/
git.rs

1//! Git-related CLI commands
2
3use anyhow::{Context, Result};
4use clap::{Parser, Subcommand};
5use tracing::{debug, error};
6
7/// Parse a `--beta-header key:value` string into a `(key, value)` tuple.
8fn parse_beta_header(s: &str) -> Result<(String, String)> {
9    let (k, v) = s.split_once(':').ok_or_else(|| {
10        anyhow::anyhow!("Invalid --beta-header format '{}'. Expected key:value", s)
11    })?;
12    Ok((k.to_string(), v.to_string()))
13}
14
15/// Git operations
16#[derive(Parser)]
17pub struct GitCommand {
18    /// Git subcommand to execute
19    #[command(subcommand)]
20    pub command: GitSubcommands,
21}
22
23/// Git subcommands
24#[derive(Subcommand)]
25pub enum GitSubcommands {
26    /// Commit-related operations
27    Commit(CommitCommand),
28    /// Branch-related operations
29    Branch(BranchCommand),
30}
31
32/// Commit operations
33#[derive(Parser)]
34pub struct CommitCommand {
35    /// Commit subcommand to execute
36    #[command(subcommand)]
37    pub command: CommitSubcommands,
38}
39
40/// Commit subcommands
41#[derive(Subcommand)]
42pub enum CommitSubcommands {
43    /// Commit message operations
44    Message(MessageCommand),
45}
46
47/// Message operations
48#[derive(Parser)]
49pub struct MessageCommand {
50    /// Message subcommand to execute
51    #[command(subcommand)]
52    pub command: MessageSubcommands,
53}
54
55/// Message subcommands
56#[derive(Subcommand)]
57pub enum MessageSubcommands {
58    /// Analyze commits and output repository information in YAML format
59    View(ViewCommand),
60    /// Amend commit messages based on a YAML configuration file
61    Amend(AmendCommand),
62    /// AI-powered commit message improvement using Claude
63    Twiddle(TwiddleCommand),
64    /// Check commit messages against guidelines without modifying them
65    Check(CheckCommand),
66}
67
68/// View command options
69#[derive(Parser)]
70pub struct ViewCommand {
71    /// Commit range to analyze (e.g., HEAD~3..HEAD, abc123..def456)
72    #[arg(value_name = "COMMIT_RANGE")]
73    pub commit_range: Option<String>,
74}
75
76/// Amend command options  
77#[derive(Parser)]
78pub struct AmendCommand {
79    /// YAML file containing commit amendments
80    #[arg(value_name = "YAML_FILE")]
81    pub yaml_file: String,
82}
83
84/// Twiddle command options
85#[derive(Parser)]
86pub struct TwiddleCommand {
87    /// Commit range to analyze and improve (e.g., HEAD~3..HEAD, abc123..def456)
88    #[arg(value_name = "COMMIT_RANGE")]
89    pub commit_range: Option<String>,
90
91    /// Claude API model to use (if not specified, uses settings or default)
92    #[arg(long)]
93    pub model: Option<String>,
94
95    /// Beta header to send with API requests (format: key:value)
96    /// Only sent if the model supports it in the registry
97    #[arg(long, value_name = "KEY:VALUE")]
98    pub beta_header: Option<String>,
99
100    /// Skip confirmation prompt and apply amendments automatically
101    #[arg(long)]
102    pub auto_apply: bool,
103
104    /// Save generated amendments to file without applying
105    #[arg(long, value_name = "FILE")]
106    pub save_only: Option<String>,
107
108    /// Use additional project context for better suggestions (Phase 3)
109    #[arg(long, default_value = "true")]
110    pub use_context: bool,
111
112    /// Path to custom context directory (defaults to .omni-dev/)
113    #[arg(long)]
114    pub context_dir: Option<std::path::PathBuf>,
115
116    /// Specify work context (e.g., "feature: user authentication")
117    #[arg(long)]
118    pub work_context: Option<String>,
119
120    /// Override detected branch context
121    #[arg(long)]
122    pub branch_context: Option<String>,
123
124    /// Disable contextual analysis (use basic prompting only)
125    #[arg(long)]
126    pub no_context: bool,
127
128    /// Maximum number of commits to process in a single batch (default: 4)
129    #[arg(long, default_value = "4")]
130    pub batch_size: usize,
131
132    /// Skip AI processing and only output repository YAML
133    #[arg(long)]
134    pub no_ai: bool,
135
136    /// Ignore existing commit messages and generate fresh ones based solely on diffs
137    #[arg(long)]
138    pub fresh: bool,
139
140    /// Run commit message validation after applying amendments
141    #[arg(long)]
142    pub check: bool,
143}
144
145/// Check command options - validates commit messages against guidelines
146#[derive(Parser)]
147pub struct CheckCommand {
148    /// Commit range to check (e.g., HEAD~3..HEAD, abc123..def456)
149    /// Defaults to commits ahead of main branch
150    #[arg(value_name = "COMMIT_RANGE")]
151    pub commit_range: Option<String>,
152
153    /// Claude API model to use (if not specified, uses settings or default)
154    #[arg(long)]
155    pub model: Option<String>,
156
157    /// Beta header to send with API requests (format: key:value)
158    /// Only sent if the model supports it in the registry
159    #[arg(long, value_name = "KEY:VALUE")]
160    pub beta_header: Option<String>,
161
162    /// Path to custom context directory (defaults to .omni-dev/)
163    #[arg(long)]
164    pub context_dir: Option<std::path::PathBuf>,
165
166    /// Explicit path to guidelines file
167    #[arg(long)]
168    pub guidelines: Option<std::path::PathBuf>,
169
170    /// Output format: text (default), json, yaml
171    #[arg(long, default_value = "text")]
172    pub format: String,
173
174    /// Exit with error code if any issues found (including warnings)
175    #[arg(long)]
176    pub strict: bool,
177
178    /// Only show errors/warnings, suppress info-level output
179    #[arg(long)]
180    pub quiet: bool,
181
182    /// Show detailed analysis including passing commits
183    #[arg(long)]
184    pub verbose: bool,
185
186    /// Include passing commits in output (hidden by default)
187    #[arg(long)]
188    pub show_passing: bool,
189
190    /// Number of commits to process per AI request (default: 4)
191    #[arg(long, default_value = "4")]
192    pub batch_size: usize,
193
194    /// Skip generating corrected message suggestions
195    #[arg(long)]
196    pub no_suggestions: bool,
197
198    /// Offer to apply suggested messages when issues are found
199    #[arg(long)]
200    pub twiddle: bool,
201}
202
203/// Branch operations
204#[derive(Parser)]
205pub struct BranchCommand {
206    /// Branch subcommand to execute
207    #[command(subcommand)]
208    pub command: BranchSubcommands,
209}
210
211/// Branch subcommands
212#[derive(Subcommand)]
213pub enum BranchSubcommands {
214    /// Analyze branch commits and output repository information in YAML format
215    Info(InfoCommand),
216    /// Create operations
217    Create(CreateCommand),
218}
219
220/// Info command options
221#[derive(Parser)]
222pub struct InfoCommand {
223    /// Base branch to compare against (defaults to main/master)
224    #[arg(value_name = "BASE_BRANCH")]
225    pub base_branch: Option<String>,
226}
227
228/// Create operations
229#[derive(Parser)]
230pub struct CreateCommand {
231    /// Create subcommand to execute
232    #[command(subcommand)]
233    pub command: CreateSubcommands,
234}
235
236/// Create subcommands
237#[derive(Subcommand)]
238pub enum CreateSubcommands {
239    /// Create a pull request with AI-generated description
240    Pr(CreatePrCommand),
241}
242
243/// Create PR command options
244#[derive(Parser)]
245pub struct CreatePrCommand {
246    /// Base branch for the PR to be merged into (defaults to main/master)
247    #[arg(long, value_name = "BRANCH")]
248    pub base: Option<String>,
249
250    /// Claude API model to use (if not specified, uses settings or default)
251    #[arg(long)]
252    pub model: Option<String>,
253
254    /// Skip confirmation prompt and create PR automatically
255    #[arg(long)]
256    pub auto_apply: bool,
257
258    /// Save generated PR details to file without creating PR
259    #[arg(long, value_name = "FILE")]
260    pub save_only: Option<String>,
261
262    /// Create PR as ready for review (overrides default)
263    #[arg(long, conflicts_with = "draft")]
264    pub ready: bool,
265
266    /// Create PR as draft (overrides default)
267    #[arg(long, conflicts_with = "ready")]
268    pub draft: bool,
269}
270
271impl GitCommand {
272    /// Execute git command
273    pub fn execute(self) -> Result<()> {
274        match self.command {
275            GitSubcommands::Commit(commit_cmd) => commit_cmd.execute(),
276            GitSubcommands::Branch(branch_cmd) => branch_cmd.execute(),
277        }
278    }
279}
280
281impl CommitCommand {
282    /// Execute commit command
283    pub fn execute(self) -> Result<()> {
284        match self.command {
285            CommitSubcommands::Message(message_cmd) => message_cmd.execute(),
286        }
287    }
288}
289
290impl MessageCommand {
291    /// Execute message command
292    pub fn execute(self) -> Result<()> {
293        match self.command {
294            MessageSubcommands::View(view_cmd) => view_cmd.execute(),
295            MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(),
296            MessageSubcommands::Twiddle(twiddle_cmd) => {
297                // Use tokio runtime for async execution
298                let rt =
299                    tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
300                rt.block_on(twiddle_cmd.execute())
301            }
302            MessageSubcommands::Check(check_cmd) => {
303                // Use tokio runtime for async execution
304                let rt =
305                    tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
306                rt.block_on(check_cmd.execute())
307            }
308        }
309    }
310}
311
312impl ViewCommand {
313    /// Execute view command
314    pub fn execute(self) -> Result<()> {
315        use crate::data::{
316            AiInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
317            WorkingDirectoryInfo,
318        };
319        use crate::git::{GitRepository, RemoteInfo};
320        use crate::utils::ai_scratch;
321
322        let commit_range = self.commit_range.as_deref().unwrap_or("HEAD");
323
324        // Open git repository
325        let repo = GitRepository::open()
326            .context("Failed to open git repository. Make sure you're in a git repository.")?;
327
328        // Get working directory status
329        let wd_status = repo.get_working_directory_status()?;
330        let working_directory = WorkingDirectoryInfo {
331            clean: wd_status.clean,
332            untracked_changes: wd_status
333                .untracked_changes
334                .into_iter()
335                .map(|fs| FileStatusInfo {
336                    status: fs.status,
337                    file: fs.file,
338                })
339                .collect(),
340        };
341
342        // Get remote information
343        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
344
345        // Parse commit range and get commits
346        let commits = repo.get_commits_in_range(commit_range)?;
347
348        // Create version information
349        let versions = Some(VersionInfo {
350            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
351        });
352
353        // Get AI scratch directory
354        let ai_scratch_path =
355            ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
356        let ai_info = AiInfo {
357            scratch: ai_scratch_path.to_string_lossy().to_string(),
358        };
359
360        // Build repository view
361        let mut repo_view = RepositoryView {
362            versions,
363            explanation: FieldExplanation::default(),
364            working_directory,
365            remotes,
366            ai: ai_info,
367            branch_info: None,
368            pr_template: None,
369            pr_template_location: None,
370            branch_prs: None,
371            commits,
372        };
373
374        // Update field presence based on actual data
375        repo_view.update_field_presence();
376
377        // Output as YAML
378        let yaml_output = crate::data::to_yaml(&repo_view)?;
379        println!("{}", yaml_output);
380
381        Ok(())
382    }
383}
384
385impl AmendCommand {
386    /// Execute amend command
387    pub fn execute(self) -> Result<()> {
388        use crate::git::AmendmentHandler;
389
390        println!("🔄 Starting commit amendment process...");
391        println!("📄 Loading amendments from: {}", self.yaml_file);
392
393        // Create amendment handler and apply amendments
394        let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
395
396        handler
397            .apply_amendments(&self.yaml_file)
398            .context("Failed to apply amendments")?;
399
400        Ok(())
401    }
402}
403
404impl TwiddleCommand {
405    /// Execute twiddle command with contextual intelligence
406    pub async fn execute(self) -> Result<()> {
407        // If --no-ai flag is set, skip AI processing and output YAML directly
408        if self.no_ai {
409            return self.execute_no_ai().await;
410        }
411
412        // Preflight check: validate AI credentials before any processing
413        let ai_info = crate::utils::check_ai_command_prerequisites(self.model.as_deref())?;
414        println!(
415            "✓ {} credentials verified (model: {})",
416            ai_info.provider, ai_info.model
417        );
418
419        // Preflight check: ensure working directory is clean before expensive operations
420        crate::utils::preflight::check_working_directory_clean()?;
421        println!("✓ Working directory is clean");
422
423        // Determine if contextual analysis should be used
424        let use_contextual = self.use_context && !self.no_context;
425
426        if use_contextual {
427            println!(
428                "🪄 Starting AI-powered commit message improvement with contextual intelligence..."
429            );
430        } else {
431            println!("🪄 Starting AI-powered commit message improvement...");
432        }
433
434        // 1. Generate repository view to get all commits
435        let mut full_repo_view = self.generate_repository_view().await?;
436
437        // 2. Check if batching is needed
438        if full_repo_view.commits.len() > self.batch_size {
439            println!(
440                "📦 Processing {} commits in batches of {} to ensure reliable analysis...",
441                full_repo_view.commits.len(),
442                self.batch_size
443            );
444            return self
445                .execute_with_batching(use_contextual, full_repo_view)
446                .await;
447        }
448
449        // 3. Collect contextual information (Phase 3)
450        let context = if use_contextual {
451            Some(self.collect_context(&full_repo_view).await?)
452        } else {
453            None
454        };
455
456        // Refine detected scopes using file_patterns from scope definitions
457        let scope_defs = match &context {
458            Some(ctx) => ctx.project.valid_scopes.clone(),
459            None => self.load_check_scopes(),
460        };
461        for commit in &mut full_repo_view.commits {
462            commit.analysis.refine_scope(&scope_defs);
463        }
464
465        // 4. Show context summary if available
466        if let Some(ref ctx) = context {
467            self.show_context_summary(ctx)?;
468        }
469
470        // 5. Initialize Claude client
471        let beta = self
472            .beta_header
473            .as_deref()
474            .map(parse_beta_header)
475            .transpose()?;
476        let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
477
478        // Show model information
479        self.show_model_info_from_client(&claude_client)?;
480
481        // 6. Generate amendments via Claude API with context
482        if self.fresh {
483            println!("🔄 Fresh mode: ignoring existing commit messages...");
484        }
485        if use_contextual && context.is_some() {
486            println!("🤖 Analyzing commits with enhanced contextual intelligence...");
487        } else {
488            println!("🤖 Analyzing commits with Claude AI...");
489        }
490
491        let amendments = if let Some(ctx) = context {
492            claude_client
493                .generate_contextual_amendments_with_options(&full_repo_view, &ctx, self.fresh)
494                .await?
495        } else {
496            claude_client
497                .generate_amendments_with_options(&full_repo_view, self.fresh)
498                .await?
499        };
500
501        // 6. Handle different output modes
502        if let Some(save_path) = self.save_only {
503            amendments.save_to_file(save_path)?;
504            println!("💾 Amendments saved to file");
505            return Ok(());
506        }
507
508        // 7. Handle amendments
509        if !amendments.amendments.is_empty() {
510            // Create temporary file for amendments
511            let temp_dir = tempfile::tempdir()?;
512            let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
513            amendments.save_to_file(&amendments_file)?;
514
515            // Show file path and get user choice
516            if !self.auto_apply && !self.handle_amendments_file(&amendments_file, &amendments)? {
517                println!("❌ Amendment cancelled by user");
518                return Ok(());
519            }
520
521            // 8. Apply amendments (re-read from file to capture any user edits)
522            self.apply_amendments_from_file(&amendments_file).await?;
523            println!("✅ Commit messages improved successfully!");
524
525            // 9. Run post-twiddle check if --check flag is set
526            if self.check {
527                self.run_post_twiddle_check().await?;
528            }
529        } else {
530            println!("✨ No commits found to process!");
531        }
532
533        Ok(())
534    }
535
536    /// Execute twiddle command with automatic batching for large commit ranges
537    async fn execute_with_batching(
538        &self,
539        use_contextual: bool,
540        full_repo_view: crate::data::RepositoryView,
541    ) -> Result<()> {
542        use crate::data::amendments::AmendmentFile;
543
544        // Initialize Claude client
545        let beta = self
546            .beta_header
547            .as_deref()
548            .map(parse_beta_header)
549            .transpose()?;
550        let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
551
552        // Show model information
553        self.show_model_info_from_client(&claude_client)?;
554
555        // Split commits into batches
556        let commit_batches: Vec<_> = full_repo_view.commits.chunks(self.batch_size).collect();
557
558        let total_batches = commit_batches.len();
559        let mut all_amendments = AmendmentFile {
560            amendments: Vec::new(),
561        };
562
563        if self.fresh {
564            println!("🔄 Fresh mode: ignoring existing commit messages...");
565        }
566        println!("📊 Processing {} batches...", total_batches);
567
568        for (batch_num, commit_batch) in commit_batches.into_iter().enumerate() {
569            println!(
570                "🔄 Processing batch {}/{} ({} commits)...",
571                batch_num + 1,
572                total_batches,
573                commit_batch.len()
574            );
575
576            // Create a repository view for just this batch
577            let mut batch_repo_view = crate::data::RepositoryView {
578                versions: full_repo_view.versions.clone(),
579                explanation: full_repo_view.explanation.clone(),
580                working_directory: full_repo_view.working_directory.clone(),
581                remotes: full_repo_view.remotes.clone(),
582                ai: full_repo_view.ai.clone(),
583                branch_info: full_repo_view.branch_info.clone(),
584                pr_template: full_repo_view.pr_template.clone(),
585                pr_template_location: full_repo_view.pr_template_location.clone(),
586                branch_prs: full_repo_view.branch_prs.clone(),
587                commits: commit_batch.to_vec(),
588            };
589
590            // Collect context for this batch if needed
591            let batch_context = if use_contextual {
592                Some(self.collect_context(&batch_repo_view).await?)
593            } else {
594                None
595            };
596
597            // Refine detected scopes using file_patterns from scope definitions
598            let batch_scope_defs = match &batch_context {
599                Some(ctx) => ctx.project.valid_scopes.clone(),
600                None => self.load_check_scopes(),
601            };
602            for commit in &mut batch_repo_view.commits {
603                commit.analysis.refine_scope(&batch_scope_defs);
604            }
605
606            // Generate amendments for this batch
607            let batch_amendments = if let Some(ctx) = batch_context {
608                claude_client
609                    .generate_contextual_amendments_with_options(&batch_repo_view, &ctx, self.fresh)
610                    .await?
611            } else {
612                claude_client
613                    .generate_amendments_with_options(&batch_repo_view, self.fresh)
614                    .await?
615            };
616
617            // Merge amendments from this batch
618            all_amendments
619                .amendments
620                .extend(batch_amendments.amendments);
621
622            if batch_num + 1 < total_batches {
623                println!("   ✅ Batch {}/{} completed", batch_num + 1, total_batches);
624                // Small delay between batches to be respectful to the API
625                tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
626            }
627        }
628
629        println!(
630            "✅ All batches completed! Found {} commits to improve.",
631            all_amendments.amendments.len()
632        );
633
634        // Handle different output modes
635        if let Some(save_path) = &self.save_only {
636            all_amendments.save_to_file(save_path)?;
637            println!("💾 Amendments saved to file");
638            return Ok(());
639        }
640
641        // Handle amendments
642        if !all_amendments.amendments.is_empty() {
643            // Create temporary file for amendments
644            let temp_dir = tempfile::tempdir()?;
645            let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
646            all_amendments.save_to_file(&amendments_file)?;
647
648            // Show file path and get user choice
649            if !self.auto_apply
650                && !self.handle_amendments_file(&amendments_file, &all_amendments)?
651            {
652                println!("❌ Amendment cancelled by user");
653                return Ok(());
654            }
655
656            // Apply all amendments (re-read from file to capture any user edits)
657            self.apply_amendments_from_file(&amendments_file).await?;
658            println!("✅ Commit messages improved successfully!");
659
660            // Run post-twiddle check if --check flag is set
661            if self.check {
662                self.run_post_twiddle_check().await?;
663            }
664        } else {
665            println!("✨ No commits found to process!");
666        }
667
668        Ok(())
669    }
670
671    /// Generate repository view (reuse ViewCommand logic)
672    async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
673        use crate::data::{
674            AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
675            WorkingDirectoryInfo,
676        };
677        use crate::git::{GitRepository, RemoteInfo};
678        use crate::utils::ai_scratch;
679
680        let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
681
682        // Open git repository
683        let repo = GitRepository::open()
684            .context("Failed to open git repository. Make sure you're in a git repository.")?;
685
686        // Get current branch name
687        let current_branch = repo
688            .get_current_branch()
689            .unwrap_or_else(|_| "HEAD".to_string());
690
691        // Get working directory status
692        let wd_status = repo.get_working_directory_status()?;
693        let working_directory = WorkingDirectoryInfo {
694            clean: wd_status.clean,
695            untracked_changes: wd_status
696                .untracked_changes
697                .into_iter()
698                .map(|fs| FileStatusInfo {
699                    status: fs.status,
700                    file: fs.file,
701                })
702                .collect(),
703        };
704
705        // Get remote information
706        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
707
708        // Parse commit range and get commits
709        let commits = repo.get_commits_in_range(commit_range)?;
710
711        // Create version information
712        let versions = Some(VersionInfo {
713            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
714        });
715
716        // Get AI scratch directory
717        let ai_scratch_path =
718            ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
719        let ai_info = AiInfo {
720            scratch: ai_scratch_path.to_string_lossy().to_string(),
721        };
722
723        // Build repository view with branch info
724        let mut repo_view = RepositoryView {
725            versions,
726            explanation: FieldExplanation::default(),
727            working_directory,
728            remotes,
729            ai: ai_info,
730            branch_info: Some(BranchInfo {
731                branch: current_branch,
732            }),
733            pr_template: None,
734            pr_template_location: None,
735            branch_prs: None,
736            commits,
737        };
738
739        // Update field presence based on actual data
740        repo_view.update_field_presence();
741
742        Ok(repo_view)
743    }
744
745    /// Handle amendments file - show path and get user choice
746    fn handle_amendments_file(
747        &self,
748        amendments_file: &std::path::Path,
749        amendments: &crate::data::amendments::AmendmentFile,
750    ) -> Result<bool> {
751        use std::io::{self, Write};
752
753        println!(
754            "\n📝 Found {} commits that could be improved.",
755            amendments.amendments.len()
756        );
757        println!("💾 Amendments saved to: {}", amendments_file.display());
758        println!();
759
760        loop {
761            print!("❓ [A]pply amendments, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] ");
762            io::stdout().flush()?;
763
764            let mut input = String::new();
765            io::stdin().read_line(&mut input)?;
766
767            match input.trim().to_lowercase().as_str() {
768                "a" | "apply" | "" => return Ok(true),
769                "s" | "show" => {
770                    self.show_amendments_file(amendments_file)?;
771                    println!();
772                }
773                "e" | "edit" => {
774                    self.edit_amendments_file(amendments_file)?;
775                    println!();
776                }
777                "q" | "quit" => return Ok(false),
778                _ => {
779                    println!(
780                        "Invalid choice. Please enter 'a' to apply, 's' to show, 'e' to edit, or 'q' to quit."
781                    );
782                }
783            }
784        }
785    }
786
787    /// Show the contents of the amendments file
788    fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
789        use std::fs;
790
791        println!("\n📄 Amendments file contents:");
792        println!("─────────────────────────────");
793
794        let contents =
795            fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
796
797        println!("{}", contents);
798        println!("─────────────────────────────");
799
800        Ok(())
801    }
802
803    /// Open the amendments file in an external editor
804    fn edit_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
805        use std::env;
806        use std::io::{self, Write};
807        use std::process::Command;
808
809        // Try to get editor from environment variables
810        let editor = env::var("OMNI_DEV_EDITOR")
811            .or_else(|_| env::var("EDITOR"))
812            .unwrap_or_else(|_| {
813                // Prompt user for editor if neither environment variable is set
814                println!(
815                    "🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
816                );
817                print!("Please enter the command to use as your editor: ");
818                io::stdout().flush().expect("Failed to flush stdout");
819
820                let mut input = String::new();
821                io::stdin()
822                    .read_line(&mut input)
823                    .expect("Failed to read user input");
824                input.trim().to_string()
825            });
826
827        if editor.is_empty() {
828            println!("❌ No editor specified. Returning to menu.");
829            return Ok(());
830        }
831
832        println!("📝 Opening amendments file in editor: {}", editor);
833
834        // Split editor command to handle arguments
835        let mut cmd_parts = editor.split_whitespace();
836        let editor_cmd = cmd_parts.next().unwrap_or(&editor);
837        let args: Vec<&str> = cmd_parts.collect();
838
839        let mut command = Command::new(editor_cmd);
840        command.args(args);
841        command.arg(amendments_file.to_string_lossy().as_ref());
842
843        match command.status() {
844            Ok(status) => {
845                if status.success() {
846                    println!("✅ Editor session completed.");
847                } else {
848                    println!(
849                        "⚠️  Editor exited with non-zero status: {:?}",
850                        status.code()
851                    );
852                }
853            }
854            Err(e) => {
855                println!("❌ Failed to execute editor '{}': {}", editor, e);
856                println!("   Please check that the editor command is correct and available in your PATH.");
857            }
858        }
859
860        Ok(())
861    }
862
863    /// Apply amendments from a file path (re-reads from disk to capture user edits)
864    async fn apply_amendments_from_file(&self, amendments_file: &std::path::Path) -> Result<()> {
865        use crate::git::AmendmentHandler;
866
867        // Use AmendmentHandler to apply amendments directly from file
868        let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
869        handler
870            .apply_amendments(&amendments_file.to_string_lossy())
871            .context("Failed to apply amendments")?;
872
873        Ok(())
874    }
875
876    /// Collect contextual information for enhanced commit message generation
877    async fn collect_context(
878        &self,
879        repo_view: &crate::data::RepositoryView,
880    ) -> Result<crate::data::context::CommitContext> {
881        use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
882        use crate::data::context::CommitContext;
883
884        let mut context = CommitContext::new();
885
886        // 1. Discover project context
887        let context_dir = self
888            .context_dir
889            .as_ref()
890            .cloned()
891            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
892
893        // ProjectDiscovery takes repo root and context directory
894        let repo_root = std::path::PathBuf::from(".");
895        let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
896        debug!(context_dir = ?context_dir, "Using context directory");
897        match discovery.discover() {
898            Ok(project_context) => {
899                debug!("Discovery successful");
900
901                // Show diagnostic information about loaded guidance files
902                self.show_guidance_files_status(&project_context, &context_dir)?;
903
904                context.project = project_context;
905            }
906            Err(e) => {
907                debug!(error = %e, "Discovery failed");
908                context.project = Default::default();
909            }
910        }
911
912        // 2. Analyze current branch from repository view
913        if let Some(branch_info) = &repo_view.branch_info {
914            context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
915        } else {
916            // Fallback to getting current branch directly if not in repo view
917            use crate::git::GitRepository;
918            let repo = GitRepository::open()?;
919            let current_branch = repo
920                .get_current_branch()
921                .unwrap_or_else(|_| "HEAD".to_string());
922            context.branch = BranchAnalyzer::analyze(&current_branch).unwrap_or_default();
923        }
924
925        // 3. Analyze commit range patterns
926        if !repo_view.commits.is_empty() {
927            context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
928        }
929
930        // 4. Apply user-provided context overrides
931        if let Some(ref work_ctx) = self.work_context {
932            context.user_provided = Some(work_ctx.clone());
933        }
934
935        if let Some(ref branch_ctx) = self.branch_context {
936            context.branch.description = branch_ctx.clone();
937        }
938
939        Ok(context)
940    }
941
942    /// Show context summary to user
943    fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
944        use crate::data::context::{VerbosityLevel, WorkPattern};
945
946        println!("🔍 Context Analysis:");
947
948        // Project context
949        if !context.project.valid_scopes.is_empty() {
950            let scope_names: Vec<&str> = context
951                .project
952                .valid_scopes
953                .iter()
954                .map(|s| s.name.as_str())
955                .collect();
956            println!("   📁 Valid scopes: {}", scope_names.join(", "));
957        }
958
959        // Branch context
960        if context.branch.is_feature_branch {
961            println!(
962                "   🌿 Branch: {} ({})",
963                context.branch.description, context.branch.work_type
964            );
965            if let Some(ref ticket) = context.branch.ticket_id {
966                println!("   🎫 Ticket: {}", ticket);
967            }
968        }
969
970        // Work pattern
971        match context.range.work_pattern {
972            WorkPattern::Sequential => println!("   🔄 Pattern: Sequential development"),
973            WorkPattern::Refactoring => println!("   🧹 Pattern: Refactoring work"),
974            WorkPattern::BugHunt => println!("   🐛 Pattern: Bug investigation"),
975            WorkPattern::Documentation => println!("   📖 Pattern: Documentation updates"),
976            WorkPattern::Configuration => println!("   ⚙️  Pattern: Configuration changes"),
977            WorkPattern::Unknown => {}
978        }
979
980        // Verbosity level
981        match context.suggested_verbosity() {
982            VerbosityLevel::Comprehensive => {
983                println!("   📝 Detail level: Comprehensive (significant changes detected)")
984            }
985            VerbosityLevel::Detailed => println!("   📝 Detail level: Detailed"),
986            VerbosityLevel::Concise => println!("   📝 Detail level: Concise"),
987        }
988
989        // User context
990        if let Some(ref user_ctx) = context.user_provided {
991            println!("   👤 User context: {}", user_ctx);
992        }
993
994        println!();
995        Ok(())
996    }
997
998    /// Show model information from actual AI client
999    fn show_model_info_from_client(
1000        &self,
1001        client: &crate::claude::client::ClaudeClient,
1002    ) -> Result<()> {
1003        use crate::claude::model_config::get_model_registry;
1004
1005        println!("🤖 AI Model Configuration:");
1006
1007        // Get actual metadata from the client
1008        let metadata = client.get_ai_client_metadata();
1009        let registry = get_model_registry();
1010
1011        if let Some(spec) = registry.get_model_spec(&metadata.model) {
1012            // Highlight the API identifier portion in yellow
1013            if metadata.model != spec.api_identifier {
1014                println!(
1015                    "   📡 Model: {} → \x1b[33m{}\x1b[0m",
1016                    metadata.model, spec.api_identifier
1017                );
1018            } else {
1019                println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1020            }
1021
1022            println!("   🏷️  Provider: {}", spec.provider);
1023            println!("   📊 Generation: {}", spec.generation);
1024            println!("   ⭐ Tier: {} ({})", spec.tier, {
1025                if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
1026                    &tier_info.description
1027                } else {
1028                    "No description available"
1029                }
1030            });
1031            println!("   📤 Max output tokens: {}", metadata.max_response_length);
1032            println!("   📥 Input context: {}", metadata.max_context_length);
1033
1034            if let Some((ref key, ref value)) = metadata.active_beta {
1035                println!("   🔬 Beta header: {}: {}", key, value);
1036            }
1037
1038            if spec.legacy {
1039                println!("   ⚠️  Legacy model (consider upgrading to newer version)");
1040            }
1041        } else {
1042            // Fallback to client metadata if not in registry
1043            println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
1044            println!("   🏷️  Provider: {}", metadata.provider);
1045            println!("   ⚠️  Model not found in registry, using client metadata:");
1046            println!("   📤 Max output tokens: {}", metadata.max_response_length);
1047            println!("   📥 Input context: {}", metadata.max_context_length);
1048        }
1049
1050        println!();
1051        Ok(())
1052    }
1053
1054    /// Show diagnostic information about loaded guidance files
1055    fn show_guidance_files_status(
1056        &self,
1057        project_context: &crate::data::context::ProjectContext,
1058        context_dir: &std::path::Path,
1059    ) -> Result<()> {
1060        println!("📋 Project guidance files status:");
1061
1062        // Check commit guidelines
1063        let guidelines_found = project_context.commit_guidelines.is_some();
1064        let guidelines_source = if guidelines_found {
1065            let local_path = context_dir.join("local").join("commit-guidelines.md");
1066            let project_path = context_dir.join("commit-guidelines.md");
1067            let home_path = dirs::home_dir()
1068                .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
1069                .unwrap_or_default();
1070
1071            if local_path.exists() {
1072                format!("✅ Local override: {}", local_path.display())
1073            } else if project_path.exists() {
1074                format!("✅ Project: {}", project_path.display())
1075            } else if home_path.exists() {
1076                format!("✅ Global: {}", home_path.display())
1077            } else {
1078                "✅ (source unknown)".to_string()
1079            }
1080        } else {
1081            "❌ None found".to_string()
1082        };
1083        println!("   📝 Commit guidelines: {}", guidelines_source);
1084
1085        // Check scopes
1086        let scopes_count = project_context.valid_scopes.len();
1087        let scopes_source = if scopes_count > 0 {
1088            let local_path = context_dir.join("local").join("scopes.yaml");
1089            let project_path = context_dir.join("scopes.yaml");
1090            let home_path = dirs::home_dir()
1091                .map(|h| h.join(".omni-dev").join("scopes.yaml"))
1092                .unwrap_or_default();
1093
1094            let source = if local_path.exists() {
1095                format!("Local override: {}", local_path.display())
1096            } else if project_path.exists() {
1097                format!("Project: {}", project_path.display())
1098            } else if home_path.exists() {
1099                format!("Global: {}", home_path.display())
1100            } else {
1101                "(source unknown + ecosystem defaults)".to_string()
1102            };
1103            format!("✅ {} ({} scopes)", source, scopes_count)
1104        } else {
1105            "❌ None found".to_string()
1106        };
1107        println!("   🎯 Valid scopes: {}", scopes_source);
1108
1109        println!();
1110        Ok(())
1111    }
1112
1113    /// Execute twiddle command without AI - create amendments with original messages
1114    async fn execute_no_ai(&self) -> Result<()> {
1115        use crate::data::amendments::{Amendment, AmendmentFile};
1116
1117        println!("📋 Generating amendments YAML without AI processing...");
1118
1119        // Generate repository view to get all commits
1120        let repo_view = self.generate_repository_view().await?;
1121
1122        // Create amendments with original commit messages (no AI improvements)
1123        let amendments: Vec<Amendment> = repo_view
1124            .commits
1125            .iter()
1126            .map(|commit| Amendment {
1127                commit: commit.hash.clone(),
1128                message: commit.original_message.clone(),
1129            })
1130            .collect();
1131
1132        let amendment_file = AmendmentFile { amendments };
1133
1134        // Handle different output modes
1135        if let Some(save_path) = &self.save_only {
1136            amendment_file.save_to_file(save_path)?;
1137            println!("💾 Amendments saved to file");
1138            return Ok(());
1139        }
1140
1141        // Handle amendments using the same flow as the AI-powered version
1142        if !amendment_file.amendments.is_empty() {
1143            // Create temporary file for amendments
1144            let temp_dir = tempfile::tempdir()?;
1145            let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
1146            amendment_file.save_to_file(&amendments_file)?;
1147
1148            // Show file path and get user choice
1149            if !self.auto_apply
1150                && !self.handle_amendments_file(&amendments_file, &amendment_file)?
1151            {
1152                println!("❌ Amendment cancelled by user");
1153                return Ok(());
1154            }
1155
1156            // Apply amendments (re-read from file to capture any user edits)
1157            self.apply_amendments_from_file(&amendments_file).await?;
1158            println!("✅ Commit messages applied successfully!");
1159
1160            // Run post-twiddle check if --check flag is set
1161            if self.check {
1162                self.run_post_twiddle_check().await?;
1163            }
1164        } else {
1165            println!("✨ No commits found to process!");
1166        }
1167
1168        Ok(())
1169    }
1170
1171    /// Run commit message validation after twiddle amendments are applied.
1172    /// If the check finds errors with suggestions, automatically applies the
1173    /// suggestions and re-checks, up to 3 retries.
1174    async fn run_post_twiddle_check(&self) -> Result<()> {
1175        use crate::data::amendments::AmendmentFile;
1176
1177        const MAX_CHECK_RETRIES: u32 = 3;
1178
1179        // Load guidelines, scopes, and Claude client once (they don't change between retries)
1180        let guidelines = self.load_check_guidelines()?;
1181        let valid_scopes = self.load_check_scopes();
1182        let beta = self
1183            .beta_header
1184            .as_deref()
1185            .map(parse_beta_header)
1186            .transpose()?;
1187        let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
1188
1189        for attempt in 0..=MAX_CHECK_RETRIES {
1190            println!();
1191            if attempt == 0 {
1192                println!("🔍 Running commit message validation...");
1193            } else {
1194                println!(
1195                    "🔍 Re-checking commit messages (retry {}/{})...",
1196                    attempt, MAX_CHECK_RETRIES
1197                );
1198            }
1199
1200            // Generate fresh repository view to get updated commit messages
1201            let mut repo_view = self.generate_repository_view().await?;
1202
1203            if repo_view.commits.is_empty() {
1204                println!("⚠️  No commits to check");
1205                return Ok(());
1206            }
1207
1208            println!("📊 Checking {} commits", repo_view.commits.len());
1209
1210            // Refine detected scopes using file_patterns from scope definitions
1211            for commit in &mut repo_view.commits {
1212                commit.analysis.refine_scope(&valid_scopes);
1213            }
1214
1215            if attempt == 0 {
1216                self.show_check_guidance_files_status(&guidelines, &valid_scopes);
1217            }
1218
1219            // Run check
1220            let report = if repo_view.commits.len() > self.batch_size {
1221                println!("📦 Checking commits in batches of {}...", self.batch_size);
1222                self.check_commits_with_batching(
1223                    &claude_client,
1224                    &repo_view,
1225                    guidelines.as_deref(),
1226                    &valid_scopes,
1227                )
1228                .await?
1229            } else {
1230                println!("🤖 Analyzing commits with AI...");
1231                claude_client
1232                    .check_commits_with_scopes(
1233                        &repo_view,
1234                        guidelines.as_deref(),
1235                        &valid_scopes,
1236                        true,
1237                    )
1238                    .await?
1239            };
1240
1241            // Output text report
1242            self.output_check_text_report(&report)?;
1243
1244            // If no errors, we're done
1245            if !report.has_errors() {
1246                if report.has_warnings() {
1247                    println!("ℹ️  Some commit messages have minor warnings");
1248                } else {
1249                    println!("✅ All commit messages pass validation");
1250                }
1251                return Ok(());
1252            }
1253
1254            // If we've exhausted retries, report and stop
1255            if attempt == MAX_CHECK_RETRIES {
1256                println!(
1257                    "⚠️  Some commit messages still have issues after {} retries",
1258                    MAX_CHECK_RETRIES
1259                );
1260                return Ok(());
1261            }
1262
1263            // Build amendments from suggestions for failing commits
1264            let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
1265
1266            if amendments.is_empty() {
1267                println!(
1268                    "⚠️  Some commit messages have issues but no suggestions available to retry"
1269                );
1270                return Ok(());
1271            }
1272
1273            // Apply the suggested amendments
1274            println!(
1275                "🔄 Applying {} suggested fix(es) and re-checking...",
1276                amendments.len()
1277            );
1278            let amendment_file = AmendmentFile { amendments };
1279            let temp_file = tempfile::NamedTempFile::new()
1280                .context("Failed to create temp file for retry amendments")?;
1281            amendment_file
1282                .save_to_file(temp_file.path())
1283                .context("Failed to save retry amendments")?;
1284            self.apply_amendments_from_file(temp_file.path()).await?;
1285        }
1286
1287        Ok(())
1288    }
1289
1290    /// Build amendments from check report suggestions for failing commits.
1291    /// Resolves short hashes from the AI response to full 40-char hashes
1292    /// from the repository view.
1293    fn build_amendments_from_suggestions(
1294        &self,
1295        report: &crate::data::check::CheckReport,
1296        repo_view: &crate::data::RepositoryView,
1297    ) -> Vec<crate::data::amendments::Amendment> {
1298        use crate::data::amendments::Amendment;
1299
1300        report
1301            .commits
1302            .iter()
1303            .filter(|r| !r.passes && r.suggestion.is_some())
1304            .filter_map(|r| {
1305                let suggestion = r.suggestion.as_ref().unwrap();
1306                // Resolve short hash to full 40-char hash
1307                let full_hash = repo_view.commits.iter().find_map(|c| {
1308                    if c.hash.starts_with(&r.hash) || r.hash.starts_with(&c.hash) {
1309                        Some(c.hash.clone())
1310                    } else {
1311                        None
1312                    }
1313                });
1314                full_hash.map(|hash| Amendment::new(hash, suggestion.message.clone()))
1315            })
1316            .collect()
1317    }
1318
1319    /// Load commit guidelines for check (mirrors CheckCommand::load_guidelines)
1320    fn load_check_guidelines(&self) -> Result<Option<String>> {
1321        use std::fs;
1322
1323        let context_dir = self
1324            .context_dir
1325            .clone()
1326            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
1327
1328        // Try local override first
1329        let local_path = context_dir.join("local").join("commit-guidelines.md");
1330        if local_path.exists() {
1331            let content = fs::read_to_string(&local_path)
1332                .with_context(|| format!("Failed to read guidelines: {:?}", local_path))?;
1333            return Ok(Some(content));
1334        }
1335
1336        // Try project-level guidelines
1337        let project_path = context_dir.join("commit-guidelines.md");
1338        if project_path.exists() {
1339            let content = fs::read_to_string(&project_path)
1340                .with_context(|| format!("Failed to read guidelines: {:?}", project_path))?;
1341            return Ok(Some(content));
1342        }
1343
1344        // Try global guidelines
1345        if let Some(home) = dirs::home_dir() {
1346            let home_path = home.join(".omni-dev").join("commit-guidelines.md");
1347            if home_path.exists() {
1348                let content = fs::read_to_string(&home_path)
1349                    .with_context(|| format!("Failed to read guidelines: {:?}", home_path))?;
1350                return Ok(Some(content));
1351            }
1352        }
1353
1354        Ok(None)
1355    }
1356
1357    /// Load valid scopes for check (mirrors CheckCommand::load_scopes)
1358    fn load_check_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
1359        use crate::data::context::ScopeDefinition;
1360        use std::fs;
1361
1362        #[derive(serde::Deserialize)]
1363        struct ScopesConfig {
1364            scopes: Vec<ScopeDefinition>,
1365        }
1366
1367        let context_dir = self
1368            .context_dir
1369            .clone()
1370            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
1371
1372        // Try local override first
1373        let local_path = context_dir.join("local").join("scopes.yaml");
1374        if local_path.exists() {
1375            if let Ok(content) = fs::read_to_string(&local_path) {
1376                if let Ok(config) = serde_yaml::from_str::<ScopesConfig>(&content) {
1377                    return config.scopes;
1378                }
1379            }
1380        }
1381
1382        // Try project-level scopes
1383        let project_path = context_dir.join("scopes.yaml");
1384        if project_path.exists() {
1385            if let Ok(content) = fs::read_to_string(&project_path) {
1386                if let Ok(config) = serde_yaml::from_str::<ScopesConfig>(&content) {
1387                    return config.scopes;
1388                }
1389            }
1390        }
1391
1392        // Try global scopes
1393        if let Some(home) = dirs::home_dir() {
1394            let home_path = home.join(".omni-dev").join("scopes.yaml");
1395            if home_path.exists() {
1396                if let Ok(content) = fs::read_to_string(&home_path) {
1397                    if let Ok(config) = serde_yaml::from_str::<ScopesConfig>(&content) {
1398                        return config.scopes;
1399                    }
1400                }
1401            }
1402        }
1403
1404        Vec::new()
1405    }
1406
1407    /// Show guidance files status for check
1408    fn show_check_guidance_files_status(
1409        &self,
1410        guidelines: &Option<String>,
1411        valid_scopes: &[crate::data::context::ScopeDefinition],
1412    ) {
1413        let context_dir = self
1414            .context_dir
1415            .clone()
1416            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
1417
1418        println!("📋 Project guidance files status:");
1419
1420        // Check commit guidelines
1421        let guidelines_found = guidelines.is_some();
1422        let guidelines_source = if guidelines_found {
1423            let local_path = context_dir.join("local").join("commit-guidelines.md");
1424            let project_path = context_dir.join("commit-guidelines.md");
1425            let home_path = dirs::home_dir()
1426                .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
1427                .unwrap_or_default();
1428
1429            if local_path.exists() {
1430                format!("✅ Local override: {}", local_path.display())
1431            } else if project_path.exists() {
1432                format!("✅ Project: {}", project_path.display())
1433            } else if home_path.exists() {
1434                format!("✅ Global: {}", home_path.display())
1435            } else {
1436                "✅ (source unknown)".to_string()
1437            }
1438        } else {
1439            "⚪ Using defaults".to_string()
1440        };
1441        println!("   📝 Commit guidelines: {}", guidelines_source);
1442
1443        // Check scopes
1444        let scopes_count = valid_scopes.len();
1445        let scopes_source = if scopes_count > 0 {
1446            let local_path = context_dir.join("local").join("scopes.yaml");
1447            let project_path = context_dir.join("scopes.yaml");
1448            let home_path = dirs::home_dir()
1449                .map(|h| h.join(".omni-dev").join("scopes.yaml"))
1450                .unwrap_or_default();
1451
1452            let source = if local_path.exists() {
1453                format!("Local override: {}", local_path.display())
1454            } else if project_path.exists() {
1455                format!("Project: {}", project_path.display())
1456            } else if home_path.exists() {
1457                format!("Global: {}", home_path.display())
1458            } else {
1459                "(source unknown)".to_string()
1460            };
1461            format!("✅ {} ({} scopes)", source, scopes_count)
1462        } else {
1463            "⚪ None found (any scope accepted)".to_string()
1464        };
1465        println!("   🎯 Valid scopes: {}", scopes_source);
1466
1467        println!();
1468    }
1469
1470    /// Check commits with batching (mirrors CheckCommand::check_with_batching)
1471    async fn check_commits_with_batching(
1472        &self,
1473        claude_client: &crate::claude::client::ClaudeClient,
1474        full_repo_view: &crate::data::RepositoryView,
1475        guidelines: Option<&str>,
1476        valid_scopes: &[crate::data::context::ScopeDefinition],
1477    ) -> Result<crate::data::check::CheckReport> {
1478        use crate::data::check::{CheckReport, CommitCheckResult};
1479
1480        let commit_batches: Vec<_> = full_repo_view.commits.chunks(self.batch_size).collect();
1481        let total_batches = commit_batches.len();
1482        let mut all_results: Vec<CommitCheckResult> = Vec::new();
1483
1484        for (batch_num, commit_batch) in commit_batches.into_iter().enumerate() {
1485            println!(
1486                "🔄 Checking batch {}/{} ({} commits)...",
1487                batch_num + 1,
1488                total_batches,
1489                commit_batch.len()
1490            );
1491
1492            let batch_repo_view = crate::data::RepositoryView {
1493                versions: full_repo_view.versions.clone(),
1494                explanation: full_repo_view.explanation.clone(),
1495                working_directory: full_repo_view.working_directory.clone(),
1496                remotes: full_repo_view.remotes.clone(),
1497                ai: full_repo_view.ai.clone(),
1498                branch_info: full_repo_view.branch_info.clone(),
1499                pr_template: full_repo_view.pr_template.clone(),
1500                pr_template_location: full_repo_view.pr_template_location.clone(),
1501                branch_prs: full_repo_view.branch_prs.clone(),
1502                commits: commit_batch.to_vec(),
1503            };
1504
1505            let batch_report = claude_client
1506                .check_commits_with_scopes(&batch_repo_view, guidelines, valid_scopes, true)
1507                .await?;
1508
1509            all_results.extend(batch_report.commits);
1510
1511            if batch_num + 1 < total_batches {
1512                tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
1513            }
1514        }
1515
1516        Ok(CheckReport::new(all_results))
1517    }
1518
1519    /// Output text format check report (mirrors CheckCommand::output_text_report)
1520    fn output_check_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
1521        use crate::data::check::IssueSeverity;
1522
1523        println!();
1524
1525        for result in &report.commits {
1526            // Skip passing commits
1527            if result.passes {
1528                continue;
1529            }
1530
1531            // Determine icon
1532            let icon = if result
1533                .issues
1534                .iter()
1535                .any(|i| i.severity == IssueSeverity::Error)
1536            {
1537                "❌"
1538            } else {
1539                "⚠️ "
1540            };
1541
1542            // Short hash
1543            let short_hash = if result.hash.len() > 7 {
1544                &result.hash[..7]
1545            } else {
1546                &result.hash
1547            };
1548
1549            println!("{} {} - \"{}\"", icon, short_hash, result.message);
1550
1551            // Print issues
1552            for issue in &result.issues {
1553                let severity_str = match issue.severity {
1554                    IssueSeverity::Error => "\x1b[31mERROR\x1b[0m  ",
1555                    IssueSeverity::Warning => "\x1b[33mWARNING\x1b[0m",
1556                    IssueSeverity::Info => "\x1b[36mINFO\x1b[0m   ",
1557                };
1558
1559                println!(
1560                    "   {} [{}] {}",
1561                    severity_str, issue.section, issue.explanation
1562                );
1563            }
1564
1565            // Print suggestion if available
1566            if let Some(suggestion) = &result.suggestion {
1567                println!();
1568                println!("   Suggested message:");
1569                for line in suggestion.message.lines() {
1570                    println!("      {}", line);
1571                }
1572            }
1573
1574            println!();
1575        }
1576
1577        // Print summary
1578        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1579        println!("Summary: {} commits checked", report.summary.total_commits);
1580        println!(
1581            "  {} errors, {} warnings",
1582            report.summary.error_count, report.summary.warning_count
1583        );
1584        println!(
1585            "  {} passed, {} with issues",
1586            report.summary.passing_commits, report.summary.failing_commits
1587        );
1588
1589        Ok(())
1590    }
1591}
1592
1593impl BranchCommand {
1594    /// Execute branch command
1595    pub fn execute(self) -> Result<()> {
1596        match self.command {
1597            BranchSubcommands::Info(info_cmd) => info_cmd.execute(),
1598            BranchSubcommands::Create(create_cmd) => {
1599                // Use tokio runtime for async execution
1600                let rt = tokio::runtime::Runtime::new()
1601                    .context("Failed to create tokio runtime for PR creation")?;
1602                rt.block_on(create_cmd.execute())
1603            }
1604        }
1605    }
1606}
1607
1608impl InfoCommand {
1609    /// Execute info command
1610    pub fn execute(self) -> Result<()> {
1611        use crate::data::{
1612            AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
1613            WorkingDirectoryInfo,
1614        };
1615        use crate::git::{GitRepository, RemoteInfo};
1616        use crate::utils::ai_scratch;
1617
1618        // Open git repository
1619        let repo = GitRepository::open()
1620            .context("Failed to open git repository. Make sure you're in a git repository.")?;
1621
1622        // Get current branch name
1623        let current_branch = repo.get_current_branch().context(
1624            "Failed to get current branch. Make sure you're not in detached HEAD state.",
1625        )?;
1626
1627        // Determine base branch
1628        let base_branch = match self.base_branch {
1629            Some(branch) => {
1630                // Validate that the specified base branch exists
1631                if !repo.branch_exists(&branch)? {
1632                    anyhow::bail!("Base branch '{}' does not exist", branch);
1633                }
1634                branch
1635            }
1636            None => {
1637                // Default to main or master
1638                if repo.branch_exists("main")? {
1639                    "main".to_string()
1640                } else if repo.branch_exists("master")? {
1641                    "master".to_string()
1642                } else {
1643                    anyhow::bail!("No default base branch found (main or master)");
1644                }
1645            }
1646        };
1647
1648        // Calculate commit range: [base_branch]..HEAD
1649        let commit_range = format!("{}..HEAD", base_branch);
1650
1651        // Get working directory status
1652        let wd_status = repo.get_working_directory_status()?;
1653        let working_directory = WorkingDirectoryInfo {
1654            clean: wd_status.clean,
1655            untracked_changes: wd_status
1656                .untracked_changes
1657                .into_iter()
1658                .map(|fs| FileStatusInfo {
1659                    status: fs.status,
1660                    file: fs.file,
1661                })
1662                .collect(),
1663        };
1664
1665        // Get remote information
1666        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1667
1668        // Parse commit range and get commits
1669        let commits = repo.get_commits_in_range(&commit_range)?;
1670
1671        // Check for PR template
1672        let pr_template_result = Self::read_pr_template().ok();
1673        let (pr_template, pr_template_location) = match pr_template_result {
1674            Some((content, location)) => (Some(content), Some(location)),
1675            None => (None, None),
1676        };
1677
1678        // Get PRs for current branch
1679        let branch_prs = Self::get_branch_prs(&current_branch)
1680            .ok()
1681            .filter(|prs| !prs.is_empty());
1682
1683        // Create version information
1684        let versions = Some(VersionInfo {
1685            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1686        });
1687
1688        // Get AI scratch directory
1689        let ai_scratch_path =
1690            ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
1691        let ai_info = AiInfo {
1692            scratch: ai_scratch_path.to_string_lossy().to_string(),
1693        };
1694
1695        // Build repository view with branch info
1696        let mut repo_view = RepositoryView {
1697            versions,
1698            explanation: FieldExplanation::default(),
1699            working_directory,
1700            remotes,
1701            ai: ai_info,
1702            branch_info: Some(BranchInfo {
1703                branch: current_branch,
1704            }),
1705            pr_template,
1706            pr_template_location,
1707            branch_prs,
1708            commits,
1709        };
1710
1711        // Update field presence based on actual data
1712        repo_view.update_field_presence();
1713
1714        // Output as YAML
1715        let yaml_output = crate::data::to_yaml(&repo_view)?;
1716        println!("{}", yaml_output);
1717
1718        Ok(())
1719    }
1720
1721    /// Read PR template file if it exists, returning both content and location
1722    fn read_pr_template() -> Result<(String, String)> {
1723        use std::fs;
1724        use std::path::Path;
1725
1726        let template_path = Path::new(".github/pull_request_template.md");
1727        if template_path.exists() {
1728            let content = fs::read_to_string(template_path)
1729                .context("Failed to read .github/pull_request_template.md")?;
1730            Ok((content, template_path.to_string_lossy().to_string()))
1731        } else {
1732            anyhow::bail!("PR template file does not exist")
1733        }
1734    }
1735
1736    /// Get pull requests for the current branch using gh CLI
1737    fn get_branch_prs(branch_name: &str) -> Result<Vec<crate::data::PullRequest>> {
1738        use serde_json::Value;
1739        use std::process::Command;
1740
1741        // Use gh CLI to get PRs for the branch
1742        let output = Command::new("gh")
1743            .args([
1744                "pr",
1745                "list",
1746                "--head",
1747                branch_name,
1748                "--json",
1749                "number,title,state,url,body,baseRefName",
1750                "--limit",
1751                "50",
1752            ])
1753            .output()
1754            .context("Failed to execute gh command")?;
1755
1756        if !output.status.success() {
1757            anyhow::bail!(
1758                "gh command failed: {}",
1759                String::from_utf8_lossy(&output.stderr)
1760            );
1761        }
1762
1763        let json_str = String::from_utf8_lossy(&output.stdout);
1764        let prs_json: Value =
1765            serde_json::from_str(&json_str).context("Failed to parse PR JSON from gh")?;
1766
1767        let mut prs = Vec::new();
1768        if let Some(prs_array) = prs_json.as_array() {
1769            for pr_json in prs_array {
1770                if let (Some(number), Some(title), Some(state), Some(url), Some(body)) = (
1771                    pr_json.get("number").and_then(|n| n.as_u64()),
1772                    pr_json.get("title").and_then(|t| t.as_str()),
1773                    pr_json.get("state").and_then(|s| s.as_str()),
1774                    pr_json.get("url").and_then(|u| u.as_str()),
1775                    pr_json.get("body").and_then(|b| b.as_str()),
1776                ) {
1777                    let base = pr_json
1778                        .get("baseRefName")
1779                        .and_then(|b| b.as_str())
1780                        .unwrap_or("")
1781                        .to_string();
1782                    prs.push(crate::data::PullRequest {
1783                        number,
1784                        title: title.to_string(),
1785                        state: state.to_string(),
1786                        url: url.to_string(),
1787                        body: body.to_string(),
1788                        base,
1789                    });
1790                }
1791            }
1792        }
1793
1794        Ok(prs)
1795    }
1796}
1797
1798/// PR action choices
1799#[derive(Debug, PartialEq)]
1800enum PrAction {
1801    CreateNew,
1802    UpdateExisting,
1803    Cancel,
1804}
1805
1806/// AI-generated PR content with structured fields
1807#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
1808pub struct PrContent {
1809    /// Concise PR title (ideally 50-80 characters)
1810    pub title: String,
1811    /// Full PR description in markdown format
1812    pub description: String,
1813}
1814
1815impl CreateCommand {
1816    /// Execute create command
1817    pub async fn execute(self) -> Result<()> {
1818        match self.command {
1819            CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute().await,
1820        }
1821    }
1822}
1823
1824impl CreatePrCommand {
1825    /// Determine if PR should be created as draft
1826    ///
1827    /// Priority order:
1828    /// 1. --ready flag (not draft)
1829    /// 2. --draft flag (draft)
1830    /// 3. OMNI_DEV_DEFAULT_DRAFT_PR env/config setting
1831    /// 4. Hard-coded default (draft)
1832    fn should_create_as_draft(&self) -> bool {
1833        use crate::utils::settings::get_env_var;
1834
1835        // Explicit flags take precedence
1836        if self.ready {
1837            return false;
1838        }
1839        if self.draft {
1840            return true;
1841        }
1842
1843        // Check configuration setting
1844        get_env_var("OMNI_DEV_DEFAULT_DRAFT_PR")
1845            .ok()
1846            .and_then(|val| match val.to_lowercase().as_str() {
1847                "true" | "1" | "yes" => Some(true),
1848                "false" | "0" | "no" => Some(false),
1849                _ => None,
1850            })
1851            .unwrap_or(true) // Default to draft if not configured
1852    }
1853
1854    /// Execute create PR command
1855    pub async fn execute(self) -> Result<()> {
1856        // Preflight check: validate all prerequisites before any processing
1857        // This catches missing credentials/tools early before wasting time
1858        let ai_info = crate::utils::check_pr_command_prerequisites(self.model.as_deref())?;
1859        println!(
1860            "✓ {} credentials verified (model: {})",
1861            ai_info.provider, ai_info.model
1862        );
1863        println!("✓ GitHub CLI verified");
1864
1865        println!("🔄 Starting pull request creation process...");
1866
1867        // 1. Generate repository view (reuse InfoCommand logic)
1868        let repo_view = self.generate_repository_view()?;
1869
1870        // 2. Validate branch state (always needed)
1871        self.validate_branch_state(&repo_view)?;
1872
1873        // 3. Show guidance files status early (before AI processing)
1874        use crate::claude::context::ProjectDiscovery;
1875        let repo_root = std::path::PathBuf::from(".");
1876        let context_dir = std::path::PathBuf::from(".omni-dev");
1877        let discovery = ProjectDiscovery::new(repo_root, context_dir);
1878        let project_context = discovery.discover().unwrap_or_default();
1879        self.show_guidance_files_status(&project_context)?;
1880
1881        // 4. Show AI model configuration before generation
1882        let claude_client = crate::claude::create_default_claude_client(self.model.clone(), None)?;
1883        self.show_model_info_from_client(&claude_client)?;
1884
1885        // 5. Show branch analysis and commit information
1886        self.show_commit_range_info(&repo_view)?;
1887
1888        // 6. Show context analysis (quick collection for display only)
1889        let context = {
1890            use crate::claude::context::{BranchAnalyzer, WorkPatternAnalyzer};
1891            use crate::data::context::CommitContext;
1892            let mut context = CommitContext::new();
1893            context.project = project_context;
1894
1895            // Quick analysis for display
1896            if let Some(branch_info) = &repo_view.branch_info {
1897                context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
1898            }
1899
1900            if !repo_view.commits.is_empty() {
1901                context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
1902            }
1903            context
1904        };
1905        self.show_context_summary(&context)?;
1906
1907        // 7. Generate AI-powered PR content (title + description)
1908        debug!("About to generate PR content from AI");
1909        let (pr_content, _claude_client) = self
1910            .generate_pr_content_with_client_internal(&repo_view, claude_client)
1911            .await?;
1912
1913        // 8. Show detailed context information (like twiddle command)
1914        self.show_context_information(&repo_view).await?;
1915        debug!(
1916            generated_title = %pr_content.title,
1917            generated_description_length = pr_content.description.len(),
1918            generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1919            "Generated PR content from AI"
1920        );
1921
1922        // 5. Handle different output modes
1923        if let Some(save_path) = self.save_only {
1924            let pr_yaml = crate::data::to_yaml(&pr_content)
1925                .context("Failed to serialize PR content to YAML")?;
1926            std::fs::write(&save_path, &pr_yaml).context("Failed to save PR details to file")?;
1927            println!("💾 PR details saved to: {}", save_path);
1928            return Ok(());
1929        }
1930
1931        // 6. Create temporary file for PR details
1932        debug!("About to serialize PR content to YAML");
1933        let temp_dir = tempfile::tempdir()?;
1934        let pr_file = temp_dir.path().join("pr-details.yaml");
1935
1936        debug!(
1937            pre_serialize_title = %pr_content.title,
1938            pre_serialize_description_length = pr_content.description.len(),
1939            pre_serialize_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1940            "About to serialize PR content with to_yaml"
1941        );
1942
1943        let pr_yaml =
1944            crate::data::to_yaml(&pr_content).context("Failed to serialize PR content to YAML")?;
1945
1946        debug!(
1947            file_path = %pr_file.display(),
1948            yaml_content_length = pr_yaml.len(),
1949            yaml_content = %pr_yaml,
1950            original_title = %pr_content.title,
1951            original_description_length = pr_content.description.len(),
1952            "Writing PR details to temporary YAML file"
1953        );
1954
1955        std::fs::write(&pr_file, &pr_yaml)?;
1956
1957        // 7. Handle PR details file - show path and get user choice
1958        let pr_action = if self.auto_apply {
1959            // For auto-apply, default to update if PR exists, otherwise create new
1960            if repo_view
1961                .branch_prs
1962                .as_ref()
1963                .is_some_and(|prs| !prs.is_empty())
1964            {
1965                PrAction::UpdateExisting
1966            } else {
1967                PrAction::CreateNew
1968            }
1969        } else {
1970            self.handle_pr_file(&pr_file, &repo_view)?
1971        };
1972
1973        if pr_action == PrAction::Cancel {
1974            println!("❌ PR operation cancelled by user");
1975            return Ok(());
1976        }
1977
1978        // 8. Create or update PR (re-read from file to capture any user edits)
1979        let final_pr_yaml =
1980            std::fs::read_to_string(&pr_file).context("Failed to read PR details file")?;
1981
1982        debug!(
1983            yaml_length = final_pr_yaml.len(),
1984            yaml_content = %final_pr_yaml,
1985            "Read PR details YAML from file"
1986        );
1987
1988        let final_pr_content: PrContent = serde_yaml::from_str(&final_pr_yaml)
1989            .context("Failed to parse PR details YAML. Please check the file format.")?;
1990
1991        debug!(
1992            title = %final_pr_content.title,
1993            description_length = final_pr_content.description.len(),
1994            description_preview = %final_pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1995            "Parsed PR content from YAML"
1996        );
1997
1998        // Determine draft status
1999        let is_draft = self.should_create_as_draft();
2000
2001        match pr_action {
2002            PrAction::CreateNew => {
2003                self.create_github_pr(
2004                    &repo_view,
2005                    &final_pr_content.title,
2006                    &final_pr_content.description,
2007                    is_draft,
2008                    self.base.as_deref(),
2009                )?;
2010                println!("✅ Pull request created successfully!");
2011            }
2012            PrAction::UpdateExisting => {
2013                self.update_github_pr(
2014                    &repo_view,
2015                    &final_pr_content.title,
2016                    &final_pr_content.description,
2017                    self.base.as_deref(),
2018                )?;
2019                println!("✅ Pull request updated successfully!");
2020            }
2021            PrAction::Cancel => unreachable!(), // Already handled above
2022        }
2023
2024        Ok(())
2025    }
2026
2027    /// Generate repository view (reuse InfoCommand logic)
2028    fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
2029        use crate::data::{
2030            AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
2031            WorkingDirectoryInfo,
2032        };
2033        use crate::git::{GitRepository, RemoteInfo};
2034        use crate::utils::ai_scratch;
2035
2036        // Open git repository
2037        let repo = GitRepository::open()
2038            .context("Failed to open git repository. Make sure you're in a git repository.")?;
2039
2040        // Get current branch name
2041        let current_branch = repo.get_current_branch().context(
2042            "Failed to get current branch. Make sure you're not in detached HEAD state.",
2043        )?;
2044
2045        // Get remote information to determine proper remote and main branch
2046        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
2047
2048        // Find the primary remote (prefer origin, fallback to first available)
2049        let primary_remote = remotes
2050            .iter()
2051            .find(|r| r.name == "origin")
2052            .or_else(|| remotes.first())
2053            .ok_or_else(|| anyhow::anyhow!("No remotes found in repository"))?;
2054
2055        // Determine base branch (with remote prefix)
2056        let base_branch = match self.base.as_ref() {
2057            Some(branch) => {
2058                // User specified base branch - try to resolve it
2059                // First, check if it's already a valid remote ref (e.g., "origin/main")
2060                let remote_ref = format!("refs/remotes/{}", branch);
2061                if repo.repository().find_reference(&remote_ref).is_ok() {
2062                    branch.clone()
2063                } else {
2064                    // Try prepending the primary remote name (e.g., "main" -> "origin/main")
2065                    let with_remote = format!("{}/{}", primary_remote.name, branch);
2066                    let remote_ref = format!("refs/remotes/{}", with_remote);
2067                    if repo.repository().find_reference(&remote_ref).is_ok() {
2068                        with_remote
2069                    } else {
2070                        anyhow::bail!(
2071                            "Remote branch '{}' does not exist (also tried '{}')",
2072                            branch,
2073                            with_remote
2074                        );
2075                    }
2076                }
2077            }
2078            None => {
2079                // Auto-detect using the primary remote's main branch
2080                let main_branch = &primary_remote.main_branch;
2081                if main_branch == "unknown" {
2082                    anyhow::bail!(
2083                        "Could not determine main branch for remote '{}'",
2084                        primary_remote.name
2085                    );
2086                }
2087
2088                let remote_main = format!("{}/{}", primary_remote.name, main_branch);
2089
2090                // Validate that the remote main branch exists
2091                let remote_ref = format!("refs/remotes/{}", remote_main);
2092                if repo.repository().find_reference(&remote_ref).is_err() {
2093                    anyhow::bail!(
2094                        "Remote main branch '{}' does not exist. Try running 'git fetch' first.",
2095                        remote_main
2096                    );
2097                }
2098
2099                remote_main
2100            }
2101        };
2102
2103        // Calculate commit range: [remote_base]..HEAD
2104        let commit_range = format!("{}..HEAD", base_branch);
2105
2106        // Get working directory status
2107        let wd_status = repo.get_working_directory_status()?;
2108        let working_directory = WorkingDirectoryInfo {
2109            clean: wd_status.clean,
2110            untracked_changes: wd_status
2111                .untracked_changes
2112                .into_iter()
2113                .map(|fs| FileStatusInfo {
2114                    status: fs.status,
2115                    file: fs.file,
2116                })
2117                .collect(),
2118        };
2119
2120        // Get remote information
2121        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
2122
2123        // Parse commit range and get commits
2124        let commits = repo.get_commits_in_range(&commit_range)?;
2125
2126        // Check for PR template
2127        let pr_template_result = InfoCommand::read_pr_template().ok();
2128        let (pr_template, pr_template_location) = match pr_template_result {
2129            Some((content, location)) => (Some(content), Some(location)),
2130            None => (None, None),
2131        };
2132
2133        // Get PRs for current branch
2134        let branch_prs = InfoCommand::get_branch_prs(&current_branch)
2135            .ok()
2136            .filter(|prs| !prs.is_empty());
2137
2138        // Create version information
2139        let versions = Some(VersionInfo {
2140            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
2141        });
2142
2143        // Get AI scratch directory
2144        let ai_scratch_path =
2145            ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
2146        let ai_info = AiInfo {
2147            scratch: ai_scratch_path.to_string_lossy().to_string(),
2148        };
2149
2150        // Build repository view with branch info
2151        let mut repo_view = RepositoryView {
2152            versions,
2153            explanation: FieldExplanation::default(),
2154            working_directory,
2155            remotes,
2156            ai: ai_info,
2157            branch_info: Some(BranchInfo {
2158                branch: current_branch,
2159            }),
2160            pr_template,
2161            pr_template_location,
2162            branch_prs,
2163            commits,
2164        };
2165
2166        // Update field presence based on actual data
2167        repo_view.update_field_presence();
2168
2169        Ok(repo_view)
2170    }
2171
2172    /// Validate branch state for PR creation
2173    fn validate_branch_state(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
2174        // Check if working directory is clean
2175        if !repo_view.working_directory.clean {
2176            anyhow::bail!(
2177                "Working directory has uncommitted changes. Please commit or stash your changes before creating a PR."
2178            );
2179        }
2180
2181        // Check if there are any untracked changes
2182        if !repo_view.working_directory.untracked_changes.is_empty() {
2183            let file_list: Vec<&str> = repo_view
2184                .working_directory
2185                .untracked_changes
2186                .iter()
2187                .map(|f| f.file.as_str())
2188                .collect();
2189            anyhow::bail!(
2190                "Working directory has untracked changes: {}. Please commit or stash your changes before creating a PR.",
2191                file_list.join(", ")
2192            );
2193        }
2194
2195        // Check if commits exist
2196        if repo_view.commits.is_empty() {
2197            anyhow::bail!("No commits found to create PR from. Make sure you have commits that are not in the base branch.");
2198        }
2199
2200        // Check if PR already exists for this branch
2201        if let Some(existing_prs) = &repo_view.branch_prs {
2202            if !existing_prs.is_empty() {
2203                let pr_info: Vec<String> = existing_prs
2204                    .iter()
2205                    .map(|pr| format!("#{} ({})", pr.number, pr.state))
2206                    .collect();
2207
2208                println!(
2209                    "📋 Existing PR(s) found for this branch: {}",
2210                    pr_info.join(", ")
2211                );
2212                // Don't bail - we'll handle this in the main flow
2213            }
2214        }
2215
2216        Ok(())
2217    }
2218
2219    /// Show detailed context information (similar to twiddle command)
2220    async fn show_context_information(
2221        &self,
2222        _repo_view: &crate::data::RepositoryView,
2223    ) -> Result<()> {
2224        // Note: commit range info and context summary are now shown earlier
2225        // This method is kept for potential future detailed information
2226        // that should be shown after AI generation
2227
2228        Ok(())
2229    }
2230
2231    /// Show commit range and count information
2232    fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
2233        // Recreate the base branch determination logic from generate_repository_view
2234        let base_branch = match self.base.as_ref() {
2235            Some(branch) => {
2236                // User specified base branch
2237                // Get the primary remote name from repo_view
2238                let primary_remote_name = repo_view
2239                    .remotes
2240                    .iter()
2241                    .find(|r| r.name == "origin")
2242                    .or_else(|| repo_view.remotes.first())
2243                    .map(|r| r.name.as_str())
2244                    .unwrap_or("origin");
2245                // Check if already has remote prefix
2246                if branch.starts_with(&format!("{}/", primary_remote_name)) {
2247                    branch.clone()
2248                } else {
2249                    format!("{}/{}", primary_remote_name, branch)
2250                }
2251            }
2252            None => {
2253                // Auto-detected base branch from remotes
2254                repo_view
2255                    .remotes
2256                    .iter()
2257                    .find(|r| r.name == "origin")
2258                    .or_else(|| repo_view.remotes.first())
2259                    .map(|r| format!("{}/{}", r.name, r.main_branch))
2260                    .unwrap_or_else(|| "unknown".to_string())
2261            }
2262        };
2263
2264        let commit_range = format!("{}..HEAD", base_branch);
2265        let commit_count = repo_view.commits.len();
2266
2267        // Get current branch name
2268        let current_branch = repo_view
2269            .branch_info
2270            .as_ref()
2271            .map(|bi| bi.branch.as_str())
2272            .unwrap_or("unknown");
2273
2274        println!("📊 Branch Analysis:");
2275        println!("   🌿 Current branch: {}", current_branch);
2276        println!("   📏 Commit range: {}", commit_range);
2277        println!("   📝 Commits found: {} commits", commit_count);
2278        println!();
2279
2280        Ok(())
2281    }
2282
2283    /// Collect contextual information for enhanced PR generation (adapted from twiddle)
2284    async fn collect_context(
2285        &self,
2286        repo_view: &crate::data::RepositoryView,
2287    ) -> Result<crate::data::context::CommitContext> {
2288        use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
2289        use crate::data::context::CommitContext;
2290        use crate::git::GitRepository;
2291
2292        let mut context = CommitContext::new();
2293
2294        // 1. Discover project context
2295        let context_dir = std::path::PathBuf::from(".omni-dev");
2296
2297        // ProjectDiscovery takes repo root and context directory
2298        let repo_root = std::path::PathBuf::from(".");
2299        let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
2300        match discovery.discover() {
2301            Ok(project_context) => {
2302                context.project = project_context;
2303            }
2304            Err(_e) => {
2305                context.project = Default::default();
2306            }
2307        }
2308
2309        // 2. Analyze current branch
2310        let repo = GitRepository::open()?;
2311        let current_branch = repo
2312            .get_current_branch()
2313            .unwrap_or_else(|_| "HEAD".to_string());
2314        context.branch = BranchAnalyzer::analyze(&current_branch).unwrap_or_default();
2315
2316        // 3. Analyze commit range patterns
2317        if !repo_view.commits.is_empty() {
2318            context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
2319        }
2320
2321        Ok(context)
2322    }
2323
2324    /// Show guidance files status (adapted from twiddle)
2325    fn show_guidance_files_status(
2326        &self,
2327        project_context: &crate::data::context::ProjectContext,
2328    ) -> Result<()> {
2329        let context_dir = std::path::PathBuf::from(".omni-dev");
2330
2331        println!("📋 Project guidance files status:");
2332
2333        // Check PR guidelines (for PR commands)
2334        let pr_guidelines_found = project_context.pr_guidelines.is_some();
2335        let pr_guidelines_source = if pr_guidelines_found {
2336            let local_path = context_dir.join("local").join("pr-guidelines.md");
2337            let project_path = context_dir.join("pr-guidelines.md");
2338            let home_path = dirs::home_dir()
2339                .map(|h| h.join(".omni-dev").join("pr-guidelines.md"))
2340                .unwrap_or_default();
2341
2342            if local_path.exists() {
2343                format!("✅ Local override: {}", local_path.display())
2344            } else if project_path.exists() {
2345                format!("✅ Project: {}", project_path.display())
2346            } else if home_path.exists() {
2347                format!("✅ Global: {}", home_path.display())
2348            } else {
2349                "✅ (source unknown)".to_string()
2350            }
2351        } else {
2352            "❌ None found".to_string()
2353        };
2354        println!("   🔀 PR guidelines: {}", pr_guidelines_source);
2355
2356        // Check scopes
2357        let scopes_count = project_context.valid_scopes.len();
2358        let scopes_source = if scopes_count > 0 {
2359            let local_path = context_dir.join("local").join("scopes.yaml");
2360            let project_path = context_dir.join("scopes.yaml");
2361            let home_path = dirs::home_dir()
2362                .map(|h| h.join(".omni-dev").join("scopes.yaml"))
2363                .unwrap_or_default();
2364
2365            let source = if local_path.exists() {
2366                format!("Local override: {}", local_path.display())
2367            } else if project_path.exists() {
2368                format!("Project: {}", project_path.display())
2369            } else if home_path.exists() {
2370                format!("Global: {}", home_path.display())
2371            } else {
2372                "(source unknown + ecosystem defaults)".to_string()
2373            };
2374            format!("✅ {} ({} scopes)", source, scopes_count)
2375        } else {
2376            "❌ None found".to_string()
2377        };
2378        println!("   🎯 Valid scopes: {}", scopes_source);
2379
2380        // Check PR template
2381        let pr_template_path = std::path::Path::new(".github/pull_request_template.md");
2382        let pr_template_status = if pr_template_path.exists() {
2383            format!("✅ Project: {}", pr_template_path.display())
2384        } else {
2385            "❌ None found".to_string()
2386        };
2387        println!("   📋 PR template: {}", pr_template_status);
2388
2389        println!();
2390        Ok(())
2391    }
2392
2393    /// Show context summary (adapted from twiddle)
2394    fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
2395        use crate::data::context::{VerbosityLevel, WorkPattern};
2396
2397        println!("🔍 Context Analysis:");
2398
2399        // Project context
2400        if !context.project.valid_scopes.is_empty() {
2401            let scope_names: Vec<&str> = context
2402                .project
2403                .valid_scopes
2404                .iter()
2405                .map(|s| s.name.as_str())
2406                .collect();
2407            println!("   📁 Valid scopes: {}", scope_names.join(", "));
2408        }
2409
2410        // Branch context
2411        if context.branch.is_feature_branch {
2412            println!(
2413                "   🌿 Branch: {} ({})",
2414                context.branch.description, context.branch.work_type
2415            );
2416            if let Some(ref ticket) = context.branch.ticket_id {
2417                println!("   🎫 Ticket: {}", ticket);
2418            }
2419        }
2420
2421        // Work pattern
2422        match context.range.work_pattern {
2423            WorkPattern::Sequential => println!("   🔄 Pattern: Sequential development"),
2424            WorkPattern::Refactoring => println!("   🧹 Pattern: Refactoring work"),
2425            WorkPattern::BugHunt => println!("   🐛 Pattern: Bug investigation"),
2426            WorkPattern::Documentation => println!("   📖 Pattern: Documentation updates"),
2427            WorkPattern::Configuration => println!("   ⚙️  Pattern: Configuration changes"),
2428            WorkPattern::Unknown => {}
2429        }
2430
2431        // Verbosity level
2432        match context.suggested_verbosity() {
2433            VerbosityLevel::Comprehensive => {
2434                println!("   📝 Detail level: Comprehensive (significant changes detected)")
2435            }
2436            VerbosityLevel::Detailed => println!("   📝 Detail level: Detailed"),
2437            VerbosityLevel::Concise => println!("   📝 Detail level: Concise"),
2438        }
2439
2440        println!();
2441        Ok(())
2442    }
2443
2444    /// Generate PR content with pre-created client (internal method that doesn't show model info)
2445    async fn generate_pr_content_with_client_internal(
2446        &self,
2447        repo_view: &crate::data::RepositoryView,
2448        claude_client: crate::claude::client::ClaudeClient,
2449    ) -> Result<(PrContent, crate::claude::client::ClaudeClient)> {
2450        use tracing::debug;
2451
2452        // Get PR template (either from repo or default)
2453        let pr_template = match &repo_view.pr_template {
2454            Some(template) => template.clone(),
2455            None => self.get_default_pr_template(),
2456        };
2457
2458        debug!(
2459            pr_template_length = pr_template.len(),
2460            pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
2461            "Using PR template for generation"
2462        );
2463
2464        println!("🤖 Generating AI-powered PR description...");
2465
2466        // Collect project context for PR guidelines
2467        debug!("Collecting context for PR generation");
2468        let context = self.collect_context(repo_view).await?;
2469        debug!("Context collection completed");
2470
2471        // Generate AI-powered PR content with context
2472        debug!("About to call Claude AI for PR content generation");
2473        match claude_client
2474            .generate_pr_content_with_context(repo_view, &pr_template, &context)
2475            .await
2476        {
2477            Ok(pr_content) => {
2478                debug!(
2479                    ai_generated_title = %pr_content.title,
2480                    ai_generated_description_length = pr_content.description.len(),
2481                    ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
2482                    "AI successfully generated PR content"
2483                );
2484                Ok((pr_content, claude_client))
2485            }
2486            Err(e) => {
2487                debug!(error = %e, "AI PR generation failed, falling back to basic description");
2488                // Fallback to basic description with commit analysis (silently)
2489                let mut description = pr_template;
2490                self.enhance_description_with_commits(&mut description, repo_view)?;
2491
2492                // Generate fallback title from commits
2493                let title = self.generate_title_from_commits(repo_view);
2494
2495                debug!(
2496                    fallback_title = %title,
2497                    fallback_description_length = description.len(),
2498                    "Created fallback PR content"
2499                );
2500
2501                Ok((PrContent { title, description }, claude_client))
2502            }
2503        }
2504    }
2505
2506    /// Get default PR template when none exists in the repository
2507    fn get_default_pr_template(&self) -> String {
2508        r#"# Pull Request
2509
2510## Description
2511<!-- Provide a brief description of what this PR does -->
2512
2513## Type of Change
2514<!-- Mark the relevant option with an "x" -->
2515- [ ] Bug fix (non-breaking change which fixes an issue)
2516- [ ] New feature (non-breaking change which adds functionality)
2517- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
2518- [ ] Documentation update
2519- [ ] Refactoring (no functional changes)
2520- [ ] Performance improvement
2521- [ ] Test coverage improvement
2522
2523## Changes Made
2524<!-- List the specific changes made in this PR -->
2525- 
2526- 
2527- 
2528
2529## Testing
2530- [ ] All existing tests pass
2531- [ ] New tests added for new functionality
2532- [ ] Manual testing performed
2533
2534## Additional Notes
2535<!-- Add any additional notes for reviewers -->
2536"#.to_string()
2537    }
2538
2539    /// Enhance PR description with commit analysis
2540    fn enhance_description_with_commits(
2541        &self,
2542        description: &mut String,
2543        repo_view: &crate::data::RepositoryView,
2544    ) -> Result<()> {
2545        if repo_view.commits.is_empty() {
2546            return Ok(());
2547        }
2548
2549        // Add commit summary section
2550        description.push_str("\n---\n");
2551        description.push_str("## 📝 Commit Summary\n");
2552        description
2553            .push_str("*This section was automatically generated based on commit analysis*\n\n");
2554
2555        // Analyze commit types and scopes
2556        let mut types_found = std::collections::HashSet::new();
2557        let mut scopes_found = std::collections::HashSet::new();
2558        let mut has_breaking_changes = false;
2559
2560        for commit in &repo_view.commits {
2561            let detected_type = &commit.analysis.detected_type;
2562            types_found.insert(detected_type.clone());
2563            if detected_type.contains("BREAKING")
2564                || commit.original_message.contains("BREAKING CHANGE")
2565            {
2566                has_breaking_changes = true;
2567            }
2568
2569            let detected_scope = &commit.analysis.detected_scope;
2570            if !detected_scope.is_empty() {
2571                scopes_found.insert(detected_scope.clone());
2572            }
2573        }
2574
2575        // Update type checkboxes based on detected types
2576        if let Some(feat_pos) = description.find("- [ ] New feature") {
2577            if types_found.contains("feat") {
2578                description.replace_range(feat_pos..feat_pos + 5, "- [x]");
2579            }
2580        }
2581        if let Some(fix_pos) = description.find("- [ ] Bug fix") {
2582            if types_found.contains("fix") {
2583                description.replace_range(fix_pos..fix_pos + 5, "- [x]");
2584            }
2585        }
2586        if let Some(docs_pos) = description.find("- [ ] Documentation update") {
2587            if types_found.contains("docs") {
2588                description.replace_range(docs_pos..docs_pos + 5, "- [x]");
2589            }
2590        }
2591        if let Some(refactor_pos) = description.find("- [ ] Refactoring") {
2592            if types_found.contains("refactor") {
2593                description.replace_range(refactor_pos..refactor_pos + 5, "- [x]");
2594            }
2595        }
2596        if let Some(breaking_pos) = description.find("- [ ] Breaking change") {
2597            if has_breaking_changes {
2598                description.replace_range(breaking_pos..breaking_pos + 5, "- [x]");
2599            }
2600        }
2601
2602        // Add detected scopes
2603        if !scopes_found.is_empty() {
2604            let scopes_list: Vec<_> = scopes_found.into_iter().collect();
2605            description.push_str(&format!(
2606                "**Affected areas:** {}\n\n",
2607                scopes_list.join(", ")
2608            ));
2609        }
2610
2611        // Add commit list
2612        description.push_str("### Commits in this PR:\n");
2613        for commit in &repo_view.commits {
2614            let short_hash = &commit.hash[..8];
2615            let first_line = commit.original_message.lines().next().unwrap_or("").trim();
2616            description.push_str(&format!("- `{}` {}\n", short_hash, first_line));
2617        }
2618
2619        // Add file change summary
2620        let total_files: usize = repo_view
2621            .commits
2622            .iter()
2623            .map(|c| c.analysis.file_changes.total_files)
2624            .sum();
2625
2626        if total_files > 0 {
2627            description.push_str(&format!("\n**Files changed:** {} files\n", total_files));
2628        }
2629
2630        Ok(())
2631    }
2632
2633    /// Handle PR description file - show path and get user choice
2634    fn handle_pr_file(
2635        &self,
2636        pr_file: &std::path::Path,
2637        repo_view: &crate::data::RepositoryView,
2638    ) -> Result<PrAction> {
2639        use std::io::{self, Write};
2640
2641        println!("\n📝 PR details generated.");
2642        println!("💾 Details saved to: {}", pr_file.display());
2643
2644        // Show draft status
2645        let is_draft = self.should_create_as_draft();
2646        let status_icon = if is_draft { "📋" } else { "✅" };
2647        let status_text = if is_draft {
2648            "draft"
2649        } else {
2650            "ready for review"
2651        };
2652        println!("{} PR will be created as: {}", status_icon, status_text);
2653        println!();
2654
2655        // Check if there are existing PRs and show different options
2656        let has_existing_prs = repo_view
2657            .branch_prs
2658            .as_ref()
2659            .is_some_and(|prs| !prs.is_empty());
2660
2661        loop {
2662            if has_existing_prs {
2663                print!("❓ [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
2664            } else {
2665                print!(
2666                    "❓ [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
2667                );
2668            }
2669            io::stdout().flush()?;
2670
2671            let mut input = String::new();
2672            io::stdin().read_line(&mut input)?;
2673
2674            match input.trim().to_lowercase().as_str() {
2675                "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
2676                "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
2677                "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
2678                "s" | "show" => {
2679                    self.show_pr_file(pr_file)?;
2680                    println!();
2681                }
2682                "e" | "edit" => {
2683                    self.edit_pr_file(pr_file)?;
2684                    println!();
2685                }
2686                "q" | "quit" => return Ok(PrAction::Cancel),
2687                _ => {
2688                    if has_existing_prs {
2689                        println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
2690                    } else {
2691                        println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
2692                    }
2693                }
2694            }
2695        }
2696    }
2697
2698    /// Show the contents of the PR details file
2699    fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
2700        use std::fs;
2701
2702        println!("\n📄 PR details file contents:");
2703        println!("─────────────────────────────");
2704
2705        let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
2706        println!("{}", contents);
2707        println!("─────────────────────────────");
2708
2709        Ok(())
2710    }
2711
2712    /// Open the PR details file in an external editor
2713    fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
2714        use std::env;
2715        use std::io::{self, Write};
2716        use std::process::Command;
2717
2718        // Try to get editor from environment variables
2719        let editor = env::var("OMNI_DEV_EDITOR")
2720            .or_else(|_| env::var("EDITOR"))
2721            .unwrap_or_else(|_| {
2722                // Prompt user for editor if neither environment variable is set
2723                println!(
2724                    "🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
2725                );
2726                print!("Please enter the command to use as your editor: ");
2727                io::stdout().flush().expect("Failed to flush stdout");
2728
2729                let mut input = String::new();
2730                io::stdin()
2731                    .read_line(&mut input)
2732                    .expect("Failed to read user input");
2733                input.trim().to_string()
2734            });
2735
2736        if editor.is_empty() {
2737            println!("❌ No editor specified. Returning to menu.");
2738            return Ok(());
2739        }
2740
2741        println!("📝 Opening PR details file in editor: {}", editor);
2742
2743        // Split editor command to handle arguments
2744        let mut cmd_parts = editor.split_whitespace();
2745        let editor_cmd = cmd_parts.next().unwrap_or(&editor);
2746        let args: Vec<&str> = cmd_parts.collect();
2747
2748        let mut command = Command::new(editor_cmd);
2749        command.args(args);
2750        command.arg(pr_file.to_string_lossy().as_ref());
2751
2752        match command.status() {
2753            Ok(status) => {
2754                if status.success() {
2755                    println!("✅ Editor session completed.");
2756                } else {
2757                    println!(
2758                        "⚠️  Editor exited with non-zero status: {:?}",
2759                        status.code()
2760                    );
2761                }
2762            }
2763            Err(e) => {
2764                println!("❌ Failed to execute editor '{}': {}", editor, e);
2765                println!("   Please check that the editor command is correct and available in your PATH.");
2766            }
2767        }
2768
2769        Ok(())
2770    }
2771
2772    /// Generate a concise title from commit analysis (fallback)
2773    fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
2774        if repo_view.commits.is_empty() {
2775            return "Pull Request".to_string();
2776        }
2777
2778        // For single commit, use its first line
2779        if repo_view.commits.len() == 1 {
2780            return repo_view.commits[0]
2781                .original_message
2782                .lines()
2783                .next()
2784                .unwrap_or("Pull Request")
2785                .trim()
2786                .to_string();
2787        }
2788
2789        // For multiple commits, generate from branch name
2790        let branch_name = repo_view
2791            .branch_info
2792            .as_ref()
2793            .map(|bi| bi.branch.as_str())
2794            .unwrap_or("feature");
2795
2796        let cleaned_branch = branch_name.replace(['/', '-', '_'], " ");
2797
2798        format!("feat: {}", cleaned_branch)
2799    }
2800
2801    /// Create new GitHub PR using gh CLI
2802    fn create_github_pr(
2803        &self,
2804        repo_view: &crate::data::RepositoryView,
2805        title: &str,
2806        description: &str,
2807        is_draft: bool,
2808        new_base: Option<&str>,
2809    ) -> Result<()> {
2810        use std::process::Command;
2811
2812        // Get branch name
2813        let branch_name = repo_view
2814            .branch_info
2815            .as_ref()
2816            .map(|bi| &bi.branch)
2817            .context("Branch info not available")?;
2818
2819        let pr_status = if is_draft {
2820            "draft"
2821        } else {
2822            "ready for review"
2823        };
2824        println!("🚀 Creating pull request ({})...", pr_status);
2825        println!("   📋 Title: {}", title);
2826        println!("   🌿 Branch: {}", branch_name);
2827        if let Some(base) = new_base {
2828            println!("   🎯 Base: {}", base);
2829        }
2830
2831        // Check if branch is pushed to remote and push if needed
2832        debug!("Opening git repository to check branch status");
2833        let git_repo =
2834            crate::git::GitRepository::open().context("Failed to open git repository")?;
2835
2836        debug!(
2837            "Checking if branch '{}' exists on remote 'origin'",
2838            branch_name
2839        );
2840        if !git_repo.branch_exists_on_remote(branch_name, "origin")? {
2841            println!("📤 Pushing branch to remote...");
2842            debug!(
2843                "Branch '{}' not found on remote, attempting to push",
2844                branch_name
2845            );
2846            git_repo
2847                .push_branch(branch_name, "origin")
2848                .context("Failed to push branch to remote")?;
2849        } else {
2850            debug!("Branch '{}' already exists on remote 'origin'", branch_name);
2851        }
2852
2853        // Create PR using gh CLI with explicit head branch
2854        debug!("Creating PR with gh CLI - title: '{}'", title);
2855        debug!("PR description length: {} characters", description.len());
2856        debug!("PR draft status: {}", is_draft);
2857        if let Some(base) = new_base {
2858            debug!("PR base branch: {}", base);
2859        }
2860
2861        let mut args = vec![
2862            "pr",
2863            "create",
2864            "--head",
2865            branch_name,
2866            "--title",
2867            title,
2868            "--body",
2869            description,
2870        ];
2871
2872        if let Some(base) = new_base {
2873            args.push("--base");
2874            args.push(base);
2875        }
2876
2877        if is_draft {
2878            args.push("--draft");
2879        }
2880
2881        let pr_result = Command::new("gh")
2882            .args(&args)
2883            .output()
2884            .context("Failed to create pull request")?;
2885
2886        if pr_result.status.success() {
2887            let pr_url = String::from_utf8_lossy(&pr_result.stdout);
2888            let pr_url = pr_url.trim();
2889            debug!("PR created successfully with URL: {}", pr_url);
2890            println!("🎉 Pull request created: {}", pr_url);
2891        } else {
2892            let error_msg = String::from_utf8_lossy(&pr_result.stderr);
2893            error!("gh CLI failed to create PR: {}", error_msg);
2894            anyhow::bail!("Failed to create pull request: {}", error_msg);
2895        }
2896
2897        Ok(())
2898    }
2899
2900    /// Update existing GitHub PR using gh CLI
2901    fn update_github_pr(
2902        &self,
2903        repo_view: &crate::data::RepositoryView,
2904        title: &str,
2905        description: &str,
2906        new_base: Option<&str>,
2907    ) -> Result<()> {
2908        use std::io::{self, Write};
2909        use std::process::Command;
2910
2911        // Get the first existing PR (assuming we're updating the most recent one)
2912        let existing_pr = repo_view
2913            .branch_prs
2914            .as_ref()
2915            .and_then(|prs| prs.first())
2916            .context("No existing PR found to update")?;
2917
2918        let pr_number = existing_pr.number;
2919        let current_base = &existing_pr.base;
2920
2921        println!("🚀 Updating pull request #{}...", pr_number);
2922        println!("   📋 Title: {}", title);
2923
2924        // Check if base branch should be changed
2925        let change_base = if let Some(base) = new_base {
2926            if !current_base.is_empty() && current_base != base {
2927                print!(
2928                    "   🎯 Current base: {} → New base: {}. Change? [y/N]: ",
2929                    current_base, base
2930                );
2931                io::stdout().flush()?;
2932
2933                let mut input = String::new();
2934                io::stdin().read_line(&mut input)?;
2935                let response = input.trim().to_lowercase();
2936                response == "y" || response == "yes"
2937            } else {
2938                false
2939            }
2940        } else {
2941            false
2942        };
2943
2944        debug!(
2945            pr_number = pr_number,
2946            title = %title,
2947            description_length = description.len(),
2948            description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
2949            change_base = change_base,
2950            "Updating GitHub PR with title and description"
2951        );
2952
2953        // Update PR using gh CLI
2954        let pr_number_str = pr_number.to_string();
2955        let mut gh_args = vec![
2956            "pr",
2957            "edit",
2958            &pr_number_str,
2959            "--title",
2960            title,
2961            "--body",
2962            description,
2963        ];
2964
2965        if change_base {
2966            if let Some(base) = new_base {
2967                gh_args.push("--base");
2968                gh_args.push(base);
2969            }
2970        }
2971
2972        debug!(
2973            args = ?gh_args,
2974            "Executing gh command to update PR"
2975        );
2976
2977        let pr_result = Command::new("gh")
2978            .args(&gh_args)
2979            .output()
2980            .context("Failed to update pull request")?;
2981
2982        if pr_result.status.success() {
2983            // Get the PR URL using the existing PR data
2984            println!("🎉 Pull request updated: {}", existing_pr.url);
2985            if change_base {
2986                if let Some(base) = new_base {
2987                    println!("   🎯 Base branch changed to: {}", base);
2988                }
2989            }
2990        } else {
2991            let error_msg = String::from_utf8_lossy(&pr_result.stderr);
2992            anyhow::bail!("Failed to update pull request: {}", error_msg);
2993        }
2994
2995        Ok(())
2996    }
2997
2998    /// Show model information from actual AI client
2999    fn show_model_info_from_client(
3000        &self,
3001        client: &crate::claude::client::ClaudeClient,
3002    ) -> Result<()> {
3003        use crate::claude::model_config::get_model_registry;
3004
3005        println!("🤖 AI Model Configuration:");
3006
3007        // Get actual metadata from the client
3008        let metadata = client.get_ai_client_metadata();
3009        let registry = get_model_registry();
3010
3011        if let Some(spec) = registry.get_model_spec(&metadata.model) {
3012            // Highlight the API identifier portion in yellow
3013            if metadata.model != spec.api_identifier {
3014                println!(
3015                    "   📡 Model: {} → \x1b[33m{}\x1b[0m",
3016                    metadata.model, spec.api_identifier
3017                );
3018            } else {
3019                println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
3020            }
3021
3022            println!("   🏷️  Provider: {}", spec.provider);
3023            println!("   📊 Generation: {}", spec.generation);
3024            println!("   ⭐ Tier: {} ({})", spec.tier, {
3025                if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
3026                    &tier_info.description
3027                } else {
3028                    "No description available"
3029                }
3030            });
3031            println!("   📤 Max output tokens: {}", metadata.max_response_length);
3032            println!("   📥 Input context: {}", metadata.max_context_length);
3033
3034            if let Some((ref key, ref value)) = metadata.active_beta {
3035                println!("   🔬 Beta header: {}: {}", key, value);
3036            }
3037
3038            if spec.legacy {
3039                println!("   ⚠️  Legacy model (consider upgrading to newer version)");
3040            }
3041        } else {
3042            // Fallback to client metadata if not in registry
3043            println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
3044            println!("   🏷️  Provider: {}", metadata.provider);
3045            println!("   ⚠️  Model not found in registry, using client metadata:");
3046            println!("   📤 Max output tokens: {}", metadata.max_response_length);
3047            println!("   📥 Input context: {}", metadata.max_context_length);
3048        }
3049
3050        println!();
3051        Ok(())
3052    }
3053}
3054
3055impl CheckCommand {
3056    /// Execute check command - validates commit messages against guidelines
3057    pub async fn execute(self) -> Result<()> {
3058        use crate::data::check::OutputFormat;
3059
3060        // Parse output format
3061        let output_format: OutputFormat = self.format.parse().unwrap_or(OutputFormat::Text);
3062
3063        // Preflight check: validate AI credentials before any processing
3064        let ai_info = crate::utils::check_ai_command_prerequisites(self.model.as_deref())?;
3065        if !self.quiet && output_format == OutputFormat::Text {
3066            println!(
3067                "✓ {} credentials verified (model: {})",
3068                ai_info.provider, ai_info.model
3069            );
3070        }
3071
3072        if !self.quiet && output_format == OutputFormat::Text {
3073            println!("🔍 Checking commit messages against guidelines...");
3074        }
3075
3076        // 1. Generate repository view to get all commits
3077        let mut repo_view = self.generate_repository_view().await?;
3078
3079        // 2. Check for empty commit range (exit code 3)
3080        if repo_view.commits.is_empty() {
3081            eprintln!("error: no commits found in range");
3082            std::process::exit(3);
3083        }
3084
3085        if !self.quiet && output_format == OutputFormat::Text {
3086            println!("📊 Found {} commits to check", repo_view.commits.len());
3087        }
3088
3089        // 3. Load commit guidelines and scopes
3090        let guidelines = self.load_guidelines().await?;
3091        let valid_scopes = self.load_scopes();
3092
3093        // Refine detected scopes using file_patterns from scope definitions
3094        for commit in &mut repo_view.commits {
3095            commit.analysis.refine_scope(&valid_scopes);
3096        }
3097
3098        if !self.quiet && output_format == OutputFormat::Text {
3099            self.show_guidance_files_status(&guidelines, &valid_scopes);
3100        }
3101
3102        // 4. Initialize Claude client
3103        let beta = self
3104            .beta_header
3105            .as_deref()
3106            .map(parse_beta_header)
3107            .transpose()?;
3108        let claude_client = crate::claude::create_default_claude_client(self.model.clone(), beta)?;
3109
3110        if self.verbose && output_format == OutputFormat::Text {
3111            self.show_model_info(&claude_client)?;
3112        }
3113
3114        // 5. Check if batching is needed
3115        let report = if repo_view.commits.len() > self.batch_size {
3116            if !self.quiet && output_format == OutputFormat::Text {
3117                println!(
3118                    "📦 Processing {} commits in batches of {}...",
3119                    repo_view.commits.len(),
3120                    self.batch_size
3121                );
3122            }
3123            self.check_with_batching(
3124                &claude_client,
3125                &repo_view,
3126                guidelines.as_deref(),
3127                &valid_scopes,
3128            )
3129            .await?
3130        } else {
3131            // 6. Single batch check
3132            if !self.quiet && output_format == OutputFormat::Text {
3133                println!("🤖 Analyzing commits with AI...");
3134            }
3135            claude_client
3136                .check_commits_with_scopes(
3137                    &repo_view,
3138                    guidelines.as_deref(),
3139                    &valid_scopes,
3140                    !self.no_suggestions,
3141                )
3142                .await?
3143        };
3144
3145        // 7. Output results
3146        self.output_report(&report, output_format)?;
3147
3148        // 8. If --twiddle and there are errors with suggestions, offer to apply them
3149        if self.twiddle && report.has_errors() && output_format == OutputFormat::Text {
3150            let amendments = self.build_amendments_from_suggestions(&report, &repo_view);
3151            if !amendments.is_empty() && self.prompt_and_apply_suggestions(amendments).await? {
3152                // Amendments applied — exit successfully
3153                return Ok(());
3154            }
3155        }
3156
3157        // 9. Determine exit code
3158        let exit_code = report.exit_code(self.strict);
3159        if exit_code != 0 {
3160            std::process::exit(exit_code);
3161        }
3162
3163        Ok(())
3164    }
3165
3166    /// Generate repository view (reuse logic from TwiddleCommand)
3167    async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
3168        use crate::data::{
3169            AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
3170            WorkingDirectoryInfo,
3171        };
3172        use crate::git::{GitRepository, RemoteInfo};
3173        use crate::utils::ai_scratch;
3174
3175        // Open git repository
3176        let repo = GitRepository::open()
3177            .context("Failed to open git repository. Make sure you're in a git repository.")?;
3178
3179        // Get current branch name
3180        let current_branch = repo
3181            .get_current_branch()
3182            .unwrap_or_else(|_| "HEAD".to_string());
3183
3184        // Determine commit range
3185        let commit_range = match &self.commit_range {
3186            Some(range) => range.clone(),
3187            None => {
3188                // Default to commits ahead of main branch
3189                let base = if repo.branch_exists("main")? {
3190                    "main"
3191                } else if repo.branch_exists("master")? {
3192                    "master"
3193                } else {
3194                    "HEAD~5"
3195                };
3196                format!("{}..HEAD", base)
3197            }
3198        };
3199
3200        // Get working directory status
3201        let wd_status = repo.get_working_directory_status()?;
3202        let working_directory = WorkingDirectoryInfo {
3203            clean: wd_status.clean,
3204            untracked_changes: wd_status
3205                .untracked_changes
3206                .into_iter()
3207                .map(|fs| FileStatusInfo {
3208                    status: fs.status,
3209                    file: fs.file,
3210                })
3211                .collect(),
3212        };
3213
3214        // Get remote information
3215        let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
3216
3217        // Parse commit range and get commits
3218        let commits = repo.get_commits_in_range(&commit_range)?;
3219
3220        // Create version information
3221        let versions = Some(VersionInfo {
3222            omni_dev: env!("CARGO_PKG_VERSION").to_string(),
3223        });
3224
3225        // Get AI scratch directory
3226        let ai_scratch_path =
3227            ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
3228        let ai_info = AiInfo {
3229            scratch: ai_scratch_path.to_string_lossy().to_string(),
3230        };
3231
3232        // Build repository view with branch info
3233        let mut repo_view = RepositoryView {
3234            versions,
3235            explanation: FieldExplanation::default(),
3236            working_directory,
3237            remotes,
3238            ai: ai_info,
3239            branch_info: Some(BranchInfo {
3240                branch: current_branch,
3241            }),
3242            pr_template: None,
3243            pr_template_location: None,
3244            branch_prs: None,
3245            commits,
3246        };
3247
3248        // Update field presence based on actual data
3249        repo_view.update_field_presence();
3250
3251        Ok(repo_view)
3252    }
3253
3254    /// Load commit guidelines from file or context directory
3255    async fn load_guidelines(&self) -> Result<Option<String>> {
3256        use std::fs;
3257
3258        // If explicit guidelines path is provided, use it
3259        if let Some(guidelines_path) = &self.guidelines {
3260            let content = fs::read_to_string(guidelines_path).with_context(|| {
3261                format!("Failed to read guidelines file: {:?}", guidelines_path)
3262            })?;
3263            return Ok(Some(content));
3264        }
3265
3266        // Otherwise, use project discovery to find guidelines
3267        let context_dir = self
3268            .context_dir
3269            .clone()
3270            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
3271
3272        // Try local override first
3273        let local_path = context_dir.join("local").join("commit-guidelines.md");
3274        if local_path.exists() {
3275            let content = fs::read_to_string(&local_path)
3276                .with_context(|| format!("Failed to read guidelines: {:?}", local_path))?;
3277            return Ok(Some(content));
3278        }
3279
3280        // Try project-level guidelines
3281        let project_path = context_dir.join("commit-guidelines.md");
3282        if project_path.exists() {
3283            let content = fs::read_to_string(&project_path)
3284                .with_context(|| format!("Failed to read guidelines: {:?}", project_path))?;
3285            return Ok(Some(content));
3286        }
3287
3288        // Try global guidelines
3289        if let Some(home) = dirs::home_dir() {
3290            let home_path = home.join(".omni-dev").join("commit-guidelines.md");
3291            if home_path.exists() {
3292                let content = fs::read_to_string(&home_path)
3293                    .with_context(|| format!("Failed to read guidelines: {:?}", home_path))?;
3294                return Ok(Some(content));
3295            }
3296        }
3297
3298        // No custom guidelines found, will use defaults
3299        Ok(None)
3300    }
3301
3302    /// Load valid scopes from context directory
3303    ///
3304    /// This ensures the check command uses the same scopes as the twiddle command,
3305    /// preventing false positives when validating commit messages.
3306    fn load_scopes(&self) -> Vec<crate::data::context::ScopeDefinition> {
3307        use crate::data::context::ScopeDefinition;
3308        use std::fs;
3309
3310        // Local config struct matching the YAML format
3311        #[derive(serde::Deserialize)]
3312        struct ScopesConfig {
3313            scopes: Vec<ScopeDefinition>,
3314        }
3315
3316        let context_dir = self
3317            .context_dir
3318            .clone()
3319            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
3320
3321        // Try local override first
3322        let local_path = context_dir.join("local").join("scopes.yaml");
3323        if local_path.exists() {
3324            if let Ok(content) = fs::read_to_string(&local_path) {
3325                if let Ok(config) = serde_yaml::from_str::<ScopesConfig>(&content) {
3326                    return config.scopes;
3327                }
3328            }
3329        }
3330
3331        // Try project-level scopes
3332        let project_path = context_dir.join("scopes.yaml");
3333        if project_path.exists() {
3334            if let Ok(content) = fs::read_to_string(&project_path) {
3335                if let Ok(config) = serde_yaml::from_str::<ScopesConfig>(&content) {
3336                    return config.scopes;
3337                }
3338            }
3339        }
3340
3341        // Try global scopes
3342        if let Some(home) = dirs::home_dir() {
3343            let home_path = home.join(".omni-dev").join("scopes.yaml");
3344            if home_path.exists() {
3345                if let Ok(content) = fs::read_to_string(&home_path) {
3346                    if let Ok(config) = serde_yaml::from_str::<ScopesConfig>(&content) {
3347                        return config.scopes;
3348                    }
3349                }
3350            }
3351        }
3352
3353        // No scopes found
3354        Vec::new()
3355    }
3356
3357    /// Show diagnostic information about loaded guidance files
3358    fn show_guidance_files_status(
3359        &self,
3360        guidelines: &Option<String>,
3361        valid_scopes: &[crate::data::context::ScopeDefinition],
3362    ) {
3363        let context_dir = self
3364            .context_dir
3365            .clone()
3366            .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
3367
3368        println!("📋 Project guidance files status:");
3369
3370        // Check commit guidelines
3371        let guidelines_found = guidelines.is_some();
3372        let guidelines_source = if guidelines_found {
3373            let local_path = context_dir.join("local").join("commit-guidelines.md");
3374            let project_path = context_dir.join("commit-guidelines.md");
3375            let home_path = dirs::home_dir()
3376                .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
3377                .unwrap_or_default();
3378
3379            if local_path.exists() {
3380                format!("✅ Local override: {}", local_path.display())
3381            } else if project_path.exists() {
3382                format!("✅ Project: {}", project_path.display())
3383            } else if home_path.exists() {
3384                format!("✅ Global: {}", home_path.display())
3385            } else {
3386                "✅ (source unknown)".to_string()
3387            }
3388        } else {
3389            "⚪ Using defaults".to_string()
3390        };
3391        println!("   📝 Commit guidelines: {}", guidelines_source);
3392
3393        // Check scopes
3394        let scopes_count = valid_scopes.len();
3395        let scopes_source = if scopes_count > 0 {
3396            let local_path = context_dir.join("local").join("scopes.yaml");
3397            let project_path = context_dir.join("scopes.yaml");
3398            let home_path = dirs::home_dir()
3399                .map(|h| h.join(".omni-dev").join("scopes.yaml"))
3400                .unwrap_or_default();
3401
3402            let source = if local_path.exists() {
3403                format!("Local override: {}", local_path.display())
3404            } else if project_path.exists() {
3405                format!("Project: {}", project_path.display())
3406            } else if home_path.exists() {
3407                format!("Global: {}", home_path.display())
3408            } else {
3409                "(source unknown)".to_string()
3410            };
3411            format!("✅ {} ({} scopes)", source, scopes_count)
3412        } else {
3413            "⚪ None found (any scope accepted)".to_string()
3414        };
3415        println!("   🎯 Valid scopes: {}", scopes_source);
3416
3417        println!();
3418    }
3419
3420    /// Check commits with batching for large commit ranges
3421    async fn check_with_batching(
3422        &self,
3423        claude_client: &crate::claude::client::ClaudeClient,
3424        full_repo_view: &crate::data::RepositoryView,
3425        guidelines: Option<&str>,
3426        valid_scopes: &[crate::data::context::ScopeDefinition],
3427    ) -> Result<crate::data::check::CheckReport> {
3428        use crate::data::check::{CheckReport, CommitCheckResult};
3429
3430        let commit_batches: Vec<_> = full_repo_view.commits.chunks(self.batch_size).collect();
3431        let total_batches = commit_batches.len();
3432        let mut all_results: Vec<CommitCheckResult> = Vec::new();
3433
3434        for (batch_num, commit_batch) in commit_batches.into_iter().enumerate() {
3435            if !self.quiet {
3436                println!(
3437                    "🔄 Processing batch {}/{} ({} commits)...",
3438                    batch_num + 1,
3439                    total_batches,
3440                    commit_batch.len()
3441                );
3442            }
3443
3444            // Create a repository view for just this batch
3445            let batch_repo_view = crate::data::RepositoryView {
3446                versions: full_repo_view.versions.clone(),
3447                explanation: full_repo_view.explanation.clone(),
3448                working_directory: full_repo_view.working_directory.clone(),
3449                remotes: full_repo_view.remotes.clone(),
3450                ai: full_repo_view.ai.clone(),
3451                branch_info: full_repo_view.branch_info.clone(),
3452                pr_template: full_repo_view.pr_template.clone(),
3453                pr_template_location: full_repo_view.pr_template_location.clone(),
3454                branch_prs: full_repo_view.branch_prs.clone(),
3455                commits: commit_batch.to_vec(),
3456            };
3457
3458            // Check this batch with scopes
3459            let batch_report = claude_client
3460                .check_commits_with_scopes(
3461                    &batch_repo_view,
3462                    guidelines,
3463                    valid_scopes,
3464                    !self.no_suggestions,
3465                )
3466                .await?;
3467
3468            // Merge results
3469            all_results.extend(batch_report.commits);
3470
3471            if batch_num + 1 < total_batches {
3472                // Small delay between batches
3473                tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
3474            }
3475        }
3476
3477        Ok(CheckReport::new(all_results))
3478    }
3479
3480    /// Output the check report in the specified format
3481    fn output_report(
3482        &self,
3483        report: &crate::data::check::CheckReport,
3484        format: crate::data::check::OutputFormat,
3485    ) -> Result<()> {
3486        use crate::data::check::OutputFormat;
3487
3488        match format {
3489            OutputFormat::Text => self.output_text_report(report),
3490            OutputFormat::Json => {
3491                let json = serde_json::to_string_pretty(report)
3492                    .context("Failed to serialize report to JSON")?;
3493                println!("{}", json);
3494                Ok(())
3495            }
3496            OutputFormat::Yaml => {
3497                let yaml =
3498                    crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
3499                println!("{}", yaml);
3500                Ok(())
3501            }
3502        }
3503    }
3504
3505    /// Output text format report
3506    fn output_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
3507        use crate::data::check::IssueSeverity;
3508
3509        println!();
3510
3511        for result in &report.commits {
3512            // Skip passing commits unless --show-passing is set
3513            if result.passes && !self.show_passing {
3514                continue;
3515            }
3516
3517            // Skip info-only commits in quiet mode
3518            if self.quiet {
3519                let has_errors_or_warnings = result
3520                    .issues
3521                    .iter()
3522                    .any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning));
3523                if !has_errors_or_warnings {
3524                    continue;
3525                }
3526            }
3527
3528            // Determine icon
3529            let icon = if result.passes {
3530                "✅"
3531            } else if result
3532                .issues
3533                .iter()
3534                .any(|i| i.severity == IssueSeverity::Error)
3535            {
3536                "❌"
3537            } else {
3538                "⚠️ "
3539            };
3540
3541            // Short hash
3542            let short_hash = if result.hash.len() > 7 {
3543                &result.hash[..7]
3544            } else {
3545                &result.hash
3546            };
3547
3548            println!("{} {} - \"{}\"", icon, short_hash, result.message);
3549
3550            // Print issues
3551            for issue in &result.issues {
3552                // Skip info issues in quiet mode
3553                if self.quiet && issue.severity == IssueSeverity::Info {
3554                    continue;
3555                }
3556
3557                let severity_str = match issue.severity {
3558                    IssueSeverity::Error => "\x1b[31mERROR\x1b[0m  ",
3559                    IssueSeverity::Warning => "\x1b[33mWARNING\x1b[0m",
3560                    IssueSeverity::Info => "\x1b[36mINFO\x1b[0m   ",
3561                };
3562
3563                println!(
3564                    "   {} [{}] {}",
3565                    severity_str, issue.section, issue.explanation
3566                );
3567            }
3568
3569            // Print suggestion if available and not in quiet mode
3570            if !self.quiet {
3571                if let Some(suggestion) = &result.suggestion {
3572                    println!();
3573                    println!("   Suggested message:");
3574                    for line in suggestion.message.lines() {
3575                        println!("      {}", line);
3576                    }
3577                    if self.verbose {
3578                        println!();
3579                        println!("   Why this is better:");
3580                        for line in suggestion.explanation.lines() {
3581                            println!("   {}", line);
3582                        }
3583                    }
3584                }
3585            }
3586
3587            println!();
3588        }
3589
3590        // Print summary
3591        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
3592        println!("Summary: {} commits checked", report.summary.total_commits);
3593        println!(
3594            "  {} errors, {} warnings",
3595            report.summary.error_count, report.summary.warning_count
3596        );
3597        println!(
3598            "  {} passed, {} with issues",
3599            report.summary.passing_commits, report.summary.failing_commits
3600        );
3601
3602        Ok(())
3603    }
3604
3605    /// Show model information
3606    fn show_model_info(&self, client: &crate::claude::client::ClaudeClient) -> Result<()> {
3607        use crate::claude::model_config::get_model_registry;
3608
3609        println!("🤖 AI Model Configuration:");
3610
3611        let metadata = client.get_ai_client_metadata();
3612        let registry = get_model_registry();
3613
3614        if let Some(spec) = registry.get_model_spec(&metadata.model) {
3615            if metadata.model != spec.api_identifier {
3616                println!(
3617                    "   📡 Model: {} → \x1b[33m{}\x1b[0m",
3618                    metadata.model, spec.api_identifier
3619                );
3620            } else {
3621                println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
3622            }
3623            println!("   🏷️  Provider: {}", spec.provider);
3624        } else {
3625            println!("   📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
3626            println!("   🏷️  Provider: {}", metadata.provider);
3627        }
3628
3629        println!();
3630        Ok(())
3631    }
3632
3633    /// Build amendments from check report suggestions for failing commits.
3634    fn build_amendments_from_suggestions(
3635        &self,
3636        report: &crate::data::check::CheckReport,
3637        repo_view: &crate::data::RepositoryView,
3638    ) -> Vec<crate::data::amendments::Amendment> {
3639        use crate::data::amendments::Amendment;
3640
3641        report
3642            .commits
3643            .iter()
3644            .filter(|r| !r.passes && r.suggestion.is_some())
3645            .filter_map(|r| {
3646                let suggestion = r.suggestion.as_ref().unwrap();
3647                let full_hash = repo_view.commits.iter().find_map(|c| {
3648                    if c.hash.starts_with(&r.hash) || r.hash.starts_with(&c.hash) {
3649                        Some(c.hash.clone())
3650                    } else {
3651                        None
3652                    }
3653                });
3654                full_hash.map(|hash| Amendment::new(hash, suggestion.message.clone()))
3655            })
3656            .collect()
3657    }
3658
3659    /// Prompt user to apply suggested amendments and apply them if accepted.
3660    /// Returns true if amendments were applied, false if user declined.
3661    async fn prompt_and_apply_suggestions(
3662        &self,
3663        amendments: Vec<crate::data::amendments::Amendment>,
3664    ) -> Result<bool> {
3665        use crate::data::amendments::AmendmentFile;
3666        use crate::git::AmendmentHandler;
3667        use std::io::{self, Write};
3668
3669        println!();
3670        println!(
3671            "🔧 {} commit(s) have issues with suggested fixes available.",
3672            amendments.len()
3673        );
3674
3675        loop {
3676            print!("❓ [A]pply suggested fixes, or [Q]uit? [A/q] ");
3677            io::stdout().flush()?;
3678
3679            let mut input = String::new();
3680            io::stdin().read_line(&mut input)?;
3681
3682            match input.trim().to_lowercase().as_str() {
3683                "a" | "apply" | "" => {
3684                    let amendment_file = AmendmentFile { amendments };
3685                    let temp_file = tempfile::NamedTempFile::new()
3686                        .context("Failed to create temp file for amendments")?;
3687                    amendment_file
3688                        .save_to_file(temp_file.path())
3689                        .context("Failed to save amendments")?;
3690
3691                    let handler = AmendmentHandler::new()
3692                        .context("Failed to initialize amendment handler")?;
3693                    handler
3694                        .apply_amendments(&temp_file.path().to_string_lossy())
3695                        .context("Failed to apply amendments")?;
3696
3697                    println!("✅ Suggested fixes applied successfully!");
3698                    return Ok(true);
3699                }
3700                "q" | "quit" => return Ok(false),
3701                _ => {
3702                    println!("Invalid choice. Please enter 'a' to apply or 'q' to quit.");
3703                }
3704            }
3705        }
3706    }
3707}