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