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