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
128#[derive(Parser)]
130pub struct CheckCommand {
131 #[arg(value_name = "COMMIT_RANGE")]
134 pub commit_range: Option<String>,
135
136 #[arg(long)]
138 pub model: Option<String>,
139
140 #[arg(long)]
142 pub context_dir: Option<std::path::PathBuf>,
143
144 #[arg(long)]
146 pub guidelines: Option<std::path::PathBuf>,
147
148 #[arg(long, default_value = "text")]
150 pub format: String,
151
152 #[arg(long)]
154 pub strict: bool,
155
156 #[arg(long)]
158 pub quiet: bool,
159
160 #[arg(long)]
162 pub verbose: bool,
163
164 #[arg(long)]
166 pub show_passing: bool,
167
168 #[arg(long, default_value = "4")]
170 pub batch_size: usize,
171
172 #[arg(long)]
174 pub no_suggestions: bool,
175}
176
177#[derive(Parser)]
179pub struct BranchCommand {
180 #[command(subcommand)]
182 pub command: BranchSubcommands,
183}
184
185#[derive(Subcommand)]
187pub enum BranchSubcommands {
188 Info(InfoCommand),
190 Create(CreateCommand),
192}
193
194#[derive(Parser)]
196pub struct InfoCommand {
197 #[arg(value_name = "BASE_BRANCH")]
199 pub base_branch: Option<String>,
200}
201
202#[derive(Parser)]
204pub struct CreateCommand {
205 #[command(subcommand)]
207 pub command: CreateSubcommands,
208}
209
210#[derive(Subcommand)]
212pub enum CreateSubcommands {
213 Pr(CreatePrCommand),
215}
216
217#[derive(Parser)]
219pub struct CreatePrCommand {
220 #[arg(long, value_name = "BRANCH")]
222 pub base: Option<String>,
223
224 #[arg(long)]
226 pub auto_apply: bool,
227
228 #[arg(long, value_name = "FILE")]
230 pub save_only: Option<String>,
231
232 #[arg(long, conflicts_with = "draft")]
234 pub ready: bool,
235
236 #[arg(long, conflicts_with = "ready")]
238 pub draft: bool,
239}
240
241impl GitCommand {
242 pub fn execute(self) -> Result<()> {
244 match self.command {
245 GitSubcommands::Commit(commit_cmd) => commit_cmd.execute(),
246 GitSubcommands::Branch(branch_cmd) => branch_cmd.execute(),
247 }
248 }
249}
250
251impl CommitCommand {
252 pub fn execute(self) -> Result<()> {
254 match self.command {
255 CommitSubcommands::Message(message_cmd) => message_cmd.execute(),
256 }
257 }
258}
259
260impl MessageCommand {
261 pub fn execute(self) -> Result<()> {
263 match self.command {
264 MessageSubcommands::View(view_cmd) => view_cmd.execute(),
265 MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(),
266 MessageSubcommands::Twiddle(twiddle_cmd) => {
267 let rt =
269 tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
270 rt.block_on(twiddle_cmd.execute())
271 }
272 MessageSubcommands::Check(check_cmd) => {
273 let rt =
275 tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
276 rt.block_on(check_cmd.execute())
277 }
278 }
279 }
280}
281
282impl ViewCommand {
283 pub fn execute(self) -> Result<()> {
285 use crate::data::{
286 AiInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
287 WorkingDirectoryInfo,
288 };
289 use crate::git::{GitRepository, RemoteInfo};
290 use crate::utils::ai_scratch;
291
292 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD");
293
294 let repo = GitRepository::open()
296 .context("Failed to open git repository. Make sure you're in a git repository.")?;
297
298 let wd_status = repo.get_working_directory_status()?;
300 let working_directory = WorkingDirectoryInfo {
301 clean: wd_status.clean,
302 untracked_changes: wd_status
303 .untracked_changes
304 .into_iter()
305 .map(|fs| FileStatusInfo {
306 status: fs.status,
307 file: fs.file,
308 })
309 .collect(),
310 };
311
312 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
314
315 let commits = repo.get_commits_in_range(commit_range)?;
317
318 let versions = Some(VersionInfo {
320 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
321 });
322
323 let ai_scratch_path =
325 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
326 let ai_info = AiInfo {
327 scratch: ai_scratch_path.to_string_lossy().to_string(),
328 };
329
330 let mut repo_view = RepositoryView {
332 versions,
333 explanation: FieldExplanation::default(),
334 working_directory,
335 remotes,
336 ai: ai_info,
337 branch_info: None,
338 pr_template: None,
339 pr_template_location: None,
340 branch_prs: None,
341 commits,
342 };
343
344 repo_view.update_field_presence();
346
347 let yaml_output = crate::data::to_yaml(&repo_view)?;
349 println!("{}", yaml_output);
350
351 Ok(())
352 }
353}
354
355impl AmendCommand {
356 pub fn execute(self) -> Result<()> {
358 use crate::git::AmendmentHandler;
359
360 println!("🔄 Starting commit amendment process...");
361 println!("📄 Loading amendments from: {}", self.yaml_file);
362
363 let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
365
366 handler
367 .apply_amendments(&self.yaml_file)
368 .context("Failed to apply amendments")?;
369
370 Ok(())
371 }
372}
373
374impl TwiddleCommand {
375 pub async fn execute(self) -> Result<()> {
377 if self.no_ai {
379 return self.execute_no_ai().await;
380 }
381
382 let use_contextual = self.use_context && !self.no_context;
384
385 if use_contextual {
386 println!(
387 "🪄 Starting AI-powered commit message improvement with contextual intelligence..."
388 );
389 } else {
390 println!("🪄 Starting AI-powered commit message improvement...");
391 }
392
393 let full_repo_view = self.generate_repository_view().await?;
395
396 if full_repo_view.commits.len() > self.batch_size {
398 println!(
399 "📦 Processing {} commits in batches of {} to ensure reliable analysis...",
400 full_repo_view.commits.len(),
401 self.batch_size
402 );
403 return self
404 .execute_with_batching(use_contextual, full_repo_view)
405 .await;
406 }
407
408 let context = if use_contextual {
410 Some(self.collect_context(&full_repo_view).await?)
411 } else {
412 None
413 };
414
415 if let Some(ref ctx) = context {
417 self.show_context_summary(ctx)?;
418 }
419
420 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
422
423 self.show_model_info_from_client(&claude_client)?;
425
426 if self.fresh {
428 println!("🔄 Fresh mode: ignoring existing commit messages...");
429 }
430 if use_contextual && context.is_some() {
431 println!("🤖 Analyzing commits with enhanced contextual intelligence...");
432 } else {
433 println!("🤖 Analyzing commits with Claude AI...");
434 }
435
436 let amendments = if let Some(ctx) = context {
437 claude_client
438 .generate_contextual_amendments_with_options(&full_repo_view, &ctx, self.fresh)
439 .await?
440 } else {
441 claude_client
442 .generate_amendments_with_options(&full_repo_view, self.fresh)
443 .await?
444 };
445
446 if let Some(save_path) = self.save_only {
448 amendments.save_to_file(save_path)?;
449 println!("💾 Amendments saved to file");
450 return Ok(());
451 }
452
453 if !amendments.amendments.is_empty() {
455 let temp_dir = tempfile::tempdir()?;
457 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
458 amendments.save_to_file(&amendments_file)?;
459
460 if !self.auto_apply && !self.handle_amendments_file(&amendments_file, &amendments)? {
462 println!("❌ Amendment cancelled by user");
463 return Ok(());
464 }
465
466 self.apply_amendments_from_file(&amendments_file).await?;
468 println!("✅ Commit messages improved successfully!");
469 } else {
470 println!("✨ No commits found to process!");
471 }
472
473 Ok(())
474 }
475
476 async fn execute_with_batching(
478 &self,
479 use_contextual: bool,
480 full_repo_view: crate::data::RepositoryView,
481 ) -> Result<()> {
482 use crate::data::amendments::AmendmentFile;
483
484 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
486
487 self.show_model_info_from_client(&claude_client)?;
489
490 let commit_batches: Vec<_> = full_repo_view.commits.chunks(self.batch_size).collect();
492
493 let total_batches = commit_batches.len();
494 let mut all_amendments = AmendmentFile {
495 amendments: Vec::new(),
496 };
497
498 if self.fresh {
499 println!("🔄 Fresh mode: ignoring existing commit messages...");
500 }
501 println!("📊 Processing {} batches...", total_batches);
502
503 for (batch_num, commit_batch) in commit_batches.into_iter().enumerate() {
504 println!(
505 "🔄 Processing batch {}/{} ({} commits)...",
506 batch_num + 1,
507 total_batches,
508 commit_batch.len()
509 );
510
511 let batch_repo_view = crate::data::RepositoryView {
513 versions: full_repo_view.versions.clone(),
514 explanation: full_repo_view.explanation.clone(),
515 working_directory: full_repo_view.working_directory.clone(),
516 remotes: full_repo_view.remotes.clone(),
517 ai: full_repo_view.ai.clone(),
518 branch_info: full_repo_view.branch_info.clone(),
519 pr_template: full_repo_view.pr_template.clone(),
520 pr_template_location: full_repo_view.pr_template_location.clone(),
521 branch_prs: full_repo_view.branch_prs.clone(),
522 commits: commit_batch.to_vec(),
523 };
524
525 let batch_context = if use_contextual {
527 Some(self.collect_context(&batch_repo_view).await?)
528 } else {
529 None
530 };
531
532 let batch_amendments = if let Some(ctx) = batch_context {
534 claude_client
535 .generate_contextual_amendments_with_options(&batch_repo_view, &ctx, self.fresh)
536 .await?
537 } else {
538 claude_client
539 .generate_amendments_with_options(&batch_repo_view, self.fresh)
540 .await?
541 };
542
543 all_amendments
545 .amendments
546 .extend(batch_amendments.amendments);
547
548 if batch_num + 1 < total_batches {
549 println!(" ✅ Batch {}/{} completed", batch_num + 1, total_batches);
550 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
552 }
553 }
554
555 println!(
556 "✅ All batches completed! Found {} commits to improve.",
557 all_amendments.amendments.len()
558 );
559
560 if let Some(save_path) = &self.save_only {
562 all_amendments.save_to_file(save_path)?;
563 println!("💾 Amendments saved to file");
564 return Ok(());
565 }
566
567 if !all_amendments.amendments.is_empty() {
569 let temp_dir = tempfile::tempdir()?;
571 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
572 all_amendments.save_to_file(&amendments_file)?;
573
574 if !self.auto_apply
576 && !self.handle_amendments_file(&amendments_file, &all_amendments)?
577 {
578 println!("❌ Amendment cancelled by user");
579 return Ok(());
580 }
581
582 self.apply_amendments_from_file(&amendments_file).await?;
584 println!("✅ Commit messages improved successfully!");
585 } else {
586 println!("✨ No commits found to process!");
587 }
588
589 Ok(())
590 }
591
592 async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
594 use crate::data::{
595 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
596 WorkingDirectoryInfo,
597 };
598 use crate::git::{GitRepository, RemoteInfo};
599 use crate::utils::ai_scratch;
600
601 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
602
603 let repo = GitRepository::open()
605 .context("Failed to open git repository. Make sure you're in a git repository.")?;
606
607 let current_branch = repo
609 .get_current_branch()
610 .unwrap_or_else(|_| "HEAD".to_string());
611
612 let wd_status = repo.get_working_directory_status()?;
614 let working_directory = WorkingDirectoryInfo {
615 clean: wd_status.clean,
616 untracked_changes: wd_status
617 .untracked_changes
618 .into_iter()
619 .map(|fs| FileStatusInfo {
620 status: fs.status,
621 file: fs.file,
622 })
623 .collect(),
624 };
625
626 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
628
629 let commits = repo.get_commits_in_range(commit_range)?;
631
632 let versions = Some(VersionInfo {
634 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
635 });
636
637 let ai_scratch_path =
639 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
640 let ai_info = AiInfo {
641 scratch: ai_scratch_path.to_string_lossy().to_string(),
642 };
643
644 let mut repo_view = RepositoryView {
646 versions,
647 explanation: FieldExplanation::default(),
648 working_directory,
649 remotes,
650 ai: ai_info,
651 branch_info: Some(BranchInfo {
652 branch: current_branch,
653 }),
654 pr_template: None,
655 pr_template_location: None,
656 branch_prs: None,
657 commits,
658 };
659
660 repo_view.update_field_presence();
662
663 Ok(repo_view)
664 }
665
666 fn handle_amendments_file(
668 &self,
669 amendments_file: &std::path::Path,
670 amendments: &crate::data::amendments::AmendmentFile,
671 ) -> Result<bool> {
672 use std::io::{self, Write};
673
674 println!(
675 "\n📝 Found {} commits that could be improved.",
676 amendments.amendments.len()
677 );
678 println!("💾 Amendments saved to: {}", amendments_file.display());
679 println!();
680
681 loop {
682 print!("❓ [A]pply amendments, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] ");
683 io::stdout().flush()?;
684
685 let mut input = String::new();
686 io::stdin().read_line(&mut input)?;
687
688 match input.trim().to_lowercase().as_str() {
689 "a" | "apply" | "" => return Ok(true),
690 "s" | "show" => {
691 self.show_amendments_file(amendments_file)?;
692 println!();
693 }
694 "e" | "edit" => {
695 self.edit_amendments_file(amendments_file)?;
696 println!();
697 }
698 "q" | "quit" => return Ok(false),
699 _ => {
700 println!(
701 "Invalid choice. Please enter 'a' to apply, 's' to show, 'e' to edit, or 'q' to quit."
702 );
703 }
704 }
705 }
706 }
707
708 fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
710 use std::fs;
711
712 println!("\n📄 Amendments file contents:");
713 println!("─────────────────────────────");
714
715 let contents =
716 fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
717
718 println!("{}", contents);
719 println!("─────────────────────────────");
720
721 Ok(())
722 }
723
724 fn edit_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
726 use std::env;
727 use std::io::{self, Write};
728 use std::process::Command;
729
730 let editor = env::var("OMNI_DEV_EDITOR")
732 .or_else(|_| env::var("EDITOR"))
733 .unwrap_or_else(|_| {
734 println!(
736 "🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
737 );
738 print!("Please enter the command to use as your editor: ");
739 io::stdout().flush().expect("Failed to flush stdout");
740
741 let mut input = String::new();
742 io::stdin()
743 .read_line(&mut input)
744 .expect("Failed to read user input");
745 input.trim().to_string()
746 });
747
748 if editor.is_empty() {
749 println!("❌ No editor specified. Returning to menu.");
750 return Ok(());
751 }
752
753 println!("📝 Opening amendments file in editor: {}", editor);
754
755 let mut cmd_parts = editor.split_whitespace();
757 let editor_cmd = cmd_parts.next().unwrap_or(&editor);
758 let args: Vec<&str> = cmd_parts.collect();
759
760 let mut command = Command::new(editor_cmd);
761 command.args(args);
762 command.arg(amendments_file.to_string_lossy().as_ref());
763
764 match command.status() {
765 Ok(status) => {
766 if status.success() {
767 println!("✅ Editor session completed.");
768 } else {
769 println!(
770 "⚠️ Editor exited with non-zero status: {:?}",
771 status.code()
772 );
773 }
774 }
775 Err(e) => {
776 println!("❌ Failed to execute editor '{}': {}", editor, e);
777 println!(" Please check that the editor command is correct and available in your PATH.");
778 }
779 }
780
781 Ok(())
782 }
783
784 async fn apply_amendments_from_file(&self, amendments_file: &std::path::Path) -> Result<()> {
786 use crate::git::AmendmentHandler;
787
788 let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
790 handler
791 .apply_amendments(&amendments_file.to_string_lossy())
792 .context("Failed to apply amendments")?;
793
794 Ok(())
795 }
796
797 async fn collect_context(
799 &self,
800 repo_view: &crate::data::RepositoryView,
801 ) -> Result<crate::data::context::CommitContext> {
802 use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
803 use crate::data::context::CommitContext;
804
805 let mut context = CommitContext::new();
806
807 let context_dir = self
809 .context_dir
810 .as_ref()
811 .cloned()
812 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
813
814 let repo_root = std::path::PathBuf::from(".");
816 let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
817 debug!(context_dir = ?context_dir, "Using context directory");
818 match discovery.discover() {
819 Ok(project_context) => {
820 debug!("Discovery successful");
821
822 self.show_guidance_files_status(&project_context, &context_dir)?;
824
825 context.project = project_context;
826 }
827 Err(e) => {
828 debug!(error = %e, "Discovery failed");
829 context.project = Default::default();
830 }
831 }
832
833 if let Some(branch_info) = &repo_view.branch_info {
835 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
836 } else {
837 use crate::git::GitRepository;
839 let repo = GitRepository::open()?;
840 let current_branch = repo
841 .get_current_branch()
842 .unwrap_or_else(|_| "HEAD".to_string());
843 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
844 }
845
846 if !repo_view.commits.is_empty() {
848 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
849 }
850
851 if let Some(ref work_ctx) = self.work_context {
853 context.user_provided = Some(work_ctx.clone());
854 }
855
856 if let Some(ref branch_ctx) = self.branch_context {
857 context.branch.description = branch_ctx.clone();
858 }
859
860 Ok(context)
861 }
862
863 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
865 use crate::data::context::{VerbosityLevel, WorkPattern};
866
867 println!("🔍 Context Analysis:");
868
869 if !context.project.valid_scopes.is_empty() {
871 let scope_names: Vec<&str> = context
872 .project
873 .valid_scopes
874 .iter()
875 .map(|s| s.name.as_str())
876 .collect();
877 println!(" 📁 Valid scopes: {}", scope_names.join(", "));
878 }
879
880 if context.branch.is_feature_branch {
882 println!(
883 " 🌿 Branch: {} ({})",
884 context.branch.description, context.branch.work_type
885 );
886 if let Some(ref ticket) = context.branch.ticket_id {
887 println!(" 🎫 Ticket: {}", ticket);
888 }
889 }
890
891 match context.range.work_pattern {
893 WorkPattern::Sequential => println!(" 🔄 Pattern: Sequential development"),
894 WorkPattern::Refactoring => println!(" 🧹 Pattern: Refactoring work"),
895 WorkPattern::BugHunt => println!(" 🐛 Pattern: Bug investigation"),
896 WorkPattern::Documentation => println!(" 📖 Pattern: Documentation updates"),
897 WorkPattern::Configuration => println!(" ⚙️ Pattern: Configuration changes"),
898 WorkPattern::Unknown => {}
899 }
900
901 match context.suggested_verbosity() {
903 VerbosityLevel::Comprehensive => {
904 println!(" 📝 Detail level: Comprehensive (significant changes detected)")
905 }
906 VerbosityLevel::Detailed => println!(" 📝 Detail level: Detailed"),
907 VerbosityLevel::Concise => println!(" 📝 Detail level: Concise"),
908 }
909
910 if let Some(ref user_ctx) = context.user_provided {
912 println!(" 👤 User context: {}", user_ctx);
913 }
914
915 println!();
916 Ok(())
917 }
918
919 fn show_model_info_from_client(
921 &self,
922 client: &crate::claude::client::ClaudeClient,
923 ) -> Result<()> {
924 use crate::claude::model_config::get_model_registry;
925
926 println!("🤖 AI Model Configuration:");
927
928 let metadata = client.get_ai_client_metadata();
930 let registry = get_model_registry();
931
932 if let Some(spec) = registry.get_model_spec(&metadata.model) {
933 if metadata.model != spec.api_identifier {
935 println!(
936 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
937 metadata.model, spec.api_identifier
938 );
939 } else {
940 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
941 }
942
943 println!(" 🏷️ Provider: {}", spec.provider);
944 println!(" 📊 Generation: {}", spec.generation);
945 println!(" ⭐ Tier: {} ({})", spec.tier, {
946 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
947 &tier_info.description
948 } else {
949 "No description available"
950 }
951 });
952 println!(" 📤 Max output tokens: {}", spec.max_output_tokens);
953 println!(" 📥 Input context: {}", spec.input_context);
954
955 if spec.legacy {
956 println!(" ⚠️ Legacy model (consider upgrading to newer version)");
957 }
958 } else {
959 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
961 println!(" 🏷️ Provider: {}", metadata.provider);
962 println!(" ⚠️ Model not found in registry, using client metadata:");
963 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
964 println!(" 📥 Input context: {}", metadata.max_context_length);
965 }
966
967 println!();
968 Ok(())
969 }
970
971 fn show_guidance_files_status(
973 &self,
974 project_context: &crate::data::context::ProjectContext,
975 context_dir: &std::path::Path,
976 ) -> Result<()> {
977 println!("📋 Project guidance files status:");
978
979 let guidelines_found = project_context.commit_guidelines.is_some();
981 let guidelines_source = if guidelines_found {
982 let local_path = context_dir.join("local").join("commit-guidelines.md");
983 let project_path = context_dir.join("commit-guidelines.md");
984 let home_path = dirs::home_dir()
985 .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
986 .unwrap_or_default();
987
988 if local_path.exists() {
989 format!("✅ Local override: {}", local_path.display())
990 } else if project_path.exists() {
991 format!("✅ Project: {}", project_path.display())
992 } else if home_path.exists() {
993 format!("✅ Global: {}", home_path.display())
994 } else {
995 "✅ (source unknown)".to_string()
996 }
997 } else {
998 "❌ None found".to_string()
999 };
1000 println!(" 📝 Commit guidelines: {}", guidelines_source);
1001
1002 let scopes_count = project_context.valid_scopes.len();
1004 let scopes_source = if scopes_count > 0 {
1005 let local_path = context_dir.join("local").join("scopes.yaml");
1006 let project_path = context_dir.join("scopes.yaml");
1007 let home_path = dirs::home_dir()
1008 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
1009 .unwrap_or_default();
1010
1011 let source = if local_path.exists() {
1012 format!("Local override: {}", local_path.display())
1013 } else if project_path.exists() {
1014 format!("Project: {}", project_path.display())
1015 } else if home_path.exists() {
1016 format!("Global: {}", home_path.display())
1017 } else {
1018 "(source unknown + ecosystem defaults)".to_string()
1019 };
1020 format!("✅ {} ({} scopes)", source, scopes_count)
1021 } else {
1022 "❌ None found".to_string()
1023 };
1024 println!(" 🎯 Valid scopes: {}", scopes_source);
1025
1026 println!();
1027 Ok(())
1028 }
1029
1030 async fn execute_no_ai(&self) -> Result<()> {
1032 use crate::data::amendments::{Amendment, AmendmentFile};
1033
1034 println!("📋 Generating amendments YAML without AI processing...");
1035
1036 let repo_view = self.generate_repository_view().await?;
1038
1039 let amendments: Vec<Amendment> = repo_view
1041 .commits
1042 .iter()
1043 .map(|commit| Amendment {
1044 commit: commit.hash.clone(),
1045 message: commit.original_message.clone(),
1046 })
1047 .collect();
1048
1049 let amendment_file = AmendmentFile { amendments };
1050
1051 if let Some(save_path) = &self.save_only {
1053 amendment_file.save_to_file(save_path)?;
1054 println!("💾 Amendments saved to file");
1055 return Ok(());
1056 }
1057
1058 if !amendment_file.amendments.is_empty() {
1060 let temp_dir = tempfile::tempdir()?;
1062 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
1063 amendment_file.save_to_file(&amendments_file)?;
1064
1065 if !self.auto_apply
1067 && !self.handle_amendments_file(&amendments_file, &amendment_file)?
1068 {
1069 println!("❌ Amendment cancelled by user");
1070 return Ok(());
1071 }
1072
1073 self.apply_amendments_from_file(&amendments_file).await?;
1075 println!("✅ Commit messages applied successfully!");
1076 } else {
1077 println!("✨ No commits found to process!");
1078 }
1079
1080 Ok(())
1081 }
1082}
1083
1084impl BranchCommand {
1085 pub fn execute(self) -> Result<()> {
1087 match self.command {
1088 BranchSubcommands::Info(info_cmd) => info_cmd.execute(),
1089 BranchSubcommands::Create(create_cmd) => {
1090 let rt = tokio::runtime::Runtime::new()
1092 .context("Failed to create tokio runtime for PR creation")?;
1093 rt.block_on(create_cmd.execute())
1094 }
1095 }
1096 }
1097}
1098
1099impl InfoCommand {
1100 pub fn execute(self) -> Result<()> {
1102 use crate::data::{
1103 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
1104 WorkingDirectoryInfo,
1105 };
1106 use crate::git::{GitRepository, RemoteInfo};
1107 use crate::utils::ai_scratch;
1108
1109 let repo = GitRepository::open()
1111 .context("Failed to open git repository. Make sure you're in a git repository.")?;
1112
1113 let current_branch = repo.get_current_branch().context(
1115 "Failed to get current branch. Make sure you're not in detached HEAD state.",
1116 )?;
1117
1118 let base_branch = match self.base_branch {
1120 Some(branch) => {
1121 if !repo.branch_exists(&branch)? {
1123 anyhow::bail!("Base branch '{}' does not exist", branch);
1124 }
1125 branch
1126 }
1127 None => {
1128 if repo.branch_exists("main")? {
1130 "main".to_string()
1131 } else if repo.branch_exists("master")? {
1132 "master".to_string()
1133 } else {
1134 anyhow::bail!("No default base branch found (main or master)");
1135 }
1136 }
1137 };
1138
1139 let commit_range = format!("{}..HEAD", base_branch);
1141
1142 let wd_status = repo.get_working_directory_status()?;
1144 let working_directory = WorkingDirectoryInfo {
1145 clean: wd_status.clean,
1146 untracked_changes: wd_status
1147 .untracked_changes
1148 .into_iter()
1149 .map(|fs| FileStatusInfo {
1150 status: fs.status,
1151 file: fs.file,
1152 })
1153 .collect(),
1154 };
1155
1156 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1158
1159 let commits = repo.get_commits_in_range(&commit_range)?;
1161
1162 let pr_template_result = Self::read_pr_template().ok();
1164 let (pr_template, pr_template_location) = match pr_template_result {
1165 Some((content, location)) => (Some(content), Some(location)),
1166 None => (None, None),
1167 };
1168
1169 let branch_prs = Self::get_branch_prs(¤t_branch)
1171 .ok()
1172 .filter(|prs| !prs.is_empty());
1173
1174 let versions = Some(VersionInfo {
1176 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1177 });
1178
1179 let ai_scratch_path =
1181 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
1182 let ai_info = AiInfo {
1183 scratch: ai_scratch_path.to_string_lossy().to_string(),
1184 };
1185
1186 let mut repo_view = RepositoryView {
1188 versions,
1189 explanation: FieldExplanation::default(),
1190 working_directory,
1191 remotes,
1192 ai: ai_info,
1193 branch_info: Some(BranchInfo {
1194 branch: current_branch,
1195 }),
1196 pr_template,
1197 pr_template_location,
1198 branch_prs,
1199 commits,
1200 };
1201
1202 repo_view.update_field_presence();
1204
1205 let yaml_output = crate::data::to_yaml(&repo_view)?;
1207 println!("{}", yaml_output);
1208
1209 Ok(())
1210 }
1211
1212 fn read_pr_template() -> Result<(String, String)> {
1214 use std::fs;
1215 use std::path::Path;
1216
1217 let template_path = Path::new(".github/pull_request_template.md");
1218 if template_path.exists() {
1219 let content = fs::read_to_string(template_path)
1220 .context("Failed to read .github/pull_request_template.md")?;
1221 Ok((content, template_path.to_string_lossy().to_string()))
1222 } else {
1223 anyhow::bail!("PR template file does not exist")
1224 }
1225 }
1226
1227 fn get_branch_prs(branch_name: &str) -> Result<Vec<crate::data::PullRequest>> {
1229 use serde_json::Value;
1230 use std::process::Command;
1231
1232 let output = Command::new("gh")
1234 .args([
1235 "pr",
1236 "list",
1237 "--head",
1238 branch_name,
1239 "--json",
1240 "number,title,state,url,body,baseRefName",
1241 "--limit",
1242 "50",
1243 ])
1244 .output()
1245 .context("Failed to execute gh command")?;
1246
1247 if !output.status.success() {
1248 anyhow::bail!(
1249 "gh command failed: {}",
1250 String::from_utf8_lossy(&output.stderr)
1251 );
1252 }
1253
1254 let json_str = String::from_utf8_lossy(&output.stdout);
1255 let prs_json: Value =
1256 serde_json::from_str(&json_str).context("Failed to parse PR JSON from gh")?;
1257
1258 let mut prs = Vec::new();
1259 if let Some(prs_array) = prs_json.as_array() {
1260 for pr_json in prs_array {
1261 if let (Some(number), Some(title), Some(state), Some(url), Some(body)) = (
1262 pr_json.get("number").and_then(|n| n.as_u64()),
1263 pr_json.get("title").and_then(|t| t.as_str()),
1264 pr_json.get("state").and_then(|s| s.as_str()),
1265 pr_json.get("url").and_then(|u| u.as_str()),
1266 pr_json.get("body").and_then(|b| b.as_str()),
1267 ) {
1268 let base = pr_json
1269 .get("baseRefName")
1270 .and_then(|b| b.as_str())
1271 .unwrap_or("")
1272 .to_string();
1273 prs.push(crate::data::PullRequest {
1274 number,
1275 title: title.to_string(),
1276 state: state.to_string(),
1277 url: url.to_string(),
1278 body: body.to_string(),
1279 base,
1280 });
1281 }
1282 }
1283 }
1284
1285 Ok(prs)
1286 }
1287}
1288
1289#[derive(Debug, PartialEq)]
1291enum PrAction {
1292 CreateNew,
1293 UpdateExisting,
1294 Cancel,
1295}
1296
1297#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
1299pub struct PrContent {
1300 pub title: String,
1302 pub description: String,
1304}
1305
1306impl CreateCommand {
1307 pub async fn execute(self) -> Result<()> {
1309 match self.command {
1310 CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute().await,
1311 }
1312 }
1313}
1314
1315impl CreatePrCommand {
1316 fn should_create_as_draft(&self) -> bool {
1324 use crate::utils::settings::get_env_var;
1325
1326 if self.ready {
1328 return false;
1329 }
1330 if self.draft {
1331 return true;
1332 }
1333
1334 get_env_var("OMNI_DEV_DEFAULT_DRAFT_PR")
1336 .ok()
1337 .and_then(|val| match val.to_lowercase().as_str() {
1338 "true" | "1" | "yes" => Some(true),
1339 "false" | "0" | "no" => Some(false),
1340 _ => None,
1341 })
1342 .unwrap_or(true) }
1344
1345 pub async fn execute(self) -> Result<()> {
1347 println!("🔄 Starting pull request creation process...");
1348
1349 let repo_view = self.generate_repository_view()?;
1351
1352 self.validate_branch_state(&repo_view)?;
1354
1355 use crate::claude::context::ProjectDiscovery;
1357 let repo_root = std::path::PathBuf::from(".");
1358 let context_dir = std::path::PathBuf::from(".omni-dev");
1359 let discovery = ProjectDiscovery::new(repo_root, context_dir);
1360 let project_context = discovery.discover().unwrap_or_default();
1361 self.show_guidance_files_status(&project_context)?;
1362
1363 let claude_client = crate::claude::create_default_claude_client(None)?;
1365 self.show_model_info_from_client(&claude_client)?;
1366
1367 self.show_commit_range_info(&repo_view)?;
1369
1370 let context = {
1372 use crate::claude::context::{BranchAnalyzer, WorkPatternAnalyzer};
1373 use crate::data::context::CommitContext;
1374 let mut context = CommitContext::new();
1375 context.project = project_context;
1376
1377 if let Some(branch_info) = &repo_view.branch_info {
1379 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
1380 }
1381
1382 if !repo_view.commits.is_empty() {
1383 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
1384 }
1385 context
1386 };
1387 self.show_context_summary(&context)?;
1388
1389 debug!("About to generate PR content from AI");
1391 let (pr_content, _claude_client) = self
1392 .generate_pr_content_with_client_internal(&repo_view, claude_client)
1393 .await?;
1394
1395 self.show_context_information(&repo_view).await?;
1397 debug!(
1398 generated_title = %pr_content.title,
1399 generated_description_length = pr_content.description.len(),
1400 generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1401 "Generated PR content from AI"
1402 );
1403
1404 if let Some(save_path) = self.save_only {
1406 let pr_yaml = crate::data::to_yaml(&pr_content)
1407 .context("Failed to serialize PR content to YAML")?;
1408 std::fs::write(&save_path, &pr_yaml).context("Failed to save PR details to file")?;
1409 println!("💾 PR details saved to: {}", save_path);
1410 return Ok(());
1411 }
1412
1413 debug!("About to serialize PR content to YAML");
1415 let temp_dir = tempfile::tempdir()?;
1416 let pr_file = temp_dir.path().join("pr-details.yaml");
1417
1418 debug!(
1419 pre_serialize_title = %pr_content.title,
1420 pre_serialize_description_length = pr_content.description.len(),
1421 pre_serialize_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1422 "About to serialize PR content with to_yaml"
1423 );
1424
1425 let pr_yaml =
1426 crate::data::to_yaml(&pr_content).context("Failed to serialize PR content to YAML")?;
1427
1428 debug!(
1429 file_path = %pr_file.display(),
1430 yaml_content_length = pr_yaml.len(),
1431 yaml_content = %pr_yaml,
1432 original_title = %pr_content.title,
1433 original_description_length = pr_content.description.len(),
1434 "Writing PR details to temporary YAML file"
1435 );
1436
1437 std::fs::write(&pr_file, &pr_yaml)?;
1438
1439 let pr_action = if self.auto_apply {
1441 if repo_view
1443 .branch_prs
1444 .as_ref()
1445 .is_some_and(|prs| !prs.is_empty())
1446 {
1447 PrAction::UpdateExisting
1448 } else {
1449 PrAction::CreateNew
1450 }
1451 } else {
1452 self.handle_pr_file(&pr_file, &repo_view)?
1453 };
1454
1455 if pr_action == PrAction::Cancel {
1456 println!("❌ PR operation cancelled by user");
1457 return Ok(());
1458 }
1459
1460 self.validate_environment()?;
1462
1463 let final_pr_yaml =
1465 std::fs::read_to_string(&pr_file).context("Failed to read PR details file")?;
1466
1467 debug!(
1468 yaml_length = final_pr_yaml.len(),
1469 yaml_content = %final_pr_yaml,
1470 "Read PR details YAML from file"
1471 );
1472
1473 let final_pr_content: PrContent = serde_yaml::from_str(&final_pr_yaml)
1474 .context("Failed to parse PR details YAML. Please check the file format.")?;
1475
1476 debug!(
1477 title = %final_pr_content.title,
1478 description_length = final_pr_content.description.len(),
1479 description_preview = %final_pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1480 "Parsed PR content from YAML"
1481 );
1482
1483 let is_draft = self.should_create_as_draft();
1485
1486 match pr_action {
1487 PrAction::CreateNew => {
1488 self.create_github_pr(
1489 &repo_view,
1490 &final_pr_content.title,
1491 &final_pr_content.description,
1492 is_draft,
1493 self.base.as_deref(),
1494 )?;
1495 println!("✅ Pull request created successfully!");
1496 }
1497 PrAction::UpdateExisting => {
1498 self.update_github_pr(
1499 &repo_view,
1500 &final_pr_content.title,
1501 &final_pr_content.description,
1502 self.base.as_deref(),
1503 )?;
1504 println!("✅ Pull request updated successfully!");
1505 }
1506 PrAction::Cancel => unreachable!(), }
1508
1509 Ok(())
1510 }
1511
1512 fn validate_environment(&self) -> Result<()> {
1514 let gh_check = std::process::Command::new("gh")
1516 .args(["--version"])
1517 .output();
1518
1519 match gh_check {
1520 Ok(output) if output.status.success() => {
1521 let repo_check = std::process::Command::new("gh")
1523 .args(["repo", "view", "--json", "name"])
1524 .output();
1525
1526 match repo_check {
1527 Ok(repo_output) if repo_output.status.success() => Ok(()),
1528 Ok(repo_output) => {
1529 let error_details = String::from_utf8_lossy(&repo_output.stderr);
1531 if error_details.contains("authentication") || error_details.contains("login") {
1532 anyhow::bail!("GitHub CLI (gh) authentication failed. Please run 'gh auth login' or check your GITHUB_TOKEN environment variable.")
1533 } else {
1534 anyhow::bail!("GitHub CLI (gh) cannot access this repository. Error: {}", error_details.trim())
1535 }
1536 }
1537 Err(e) => anyhow::bail!("Failed to test GitHub CLI access: {}", e),
1538 }
1539 }
1540 _ => anyhow::bail!("GitHub CLI (gh) is not installed or not available in PATH. Please install it from https://cli.github.com/"),
1541 }
1542 }
1543
1544 fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
1546 use crate::data::{
1547 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
1548 WorkingDirectoryInfo,
1549 };
1550 use crate::git::{GitRepository, RemoteInfo};
1551 use crate::utils::ai_scratch;
1552
1553 let repo = GitRepository::open()
1555 .context("Failed to open git repository. Make sure you're in a git repository.")?;
1556
1557 let current_branch = repo.get_current_branch().context(
1559 "Failed to get current branch. Make sure you're not in detached HEAD state.",
1560 )?;
1561
1562 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1564
1565 let primary_remote = remotes
1567 .iter()
1568 .find(|r| r.name == "origin")
1569 .or_else(|| remotes.first())
1570 .ok_or_else(|| anyhow::anyhow!("No remotes found in repository"))?;
1571
1572 let base_branch = match self.base.as_ref() {
1574 Some(branch) => {
1575 let remote_ref = format!("refs/remotes/{}", branch);
1578 if repo.repository().find_reference(&remote_ref).is_ok() {
1579 branch.clone()
1580 } else {
1581 let with_remote = format!("{}/{}", primary_remote.name, branch);
1583 let remote_ref = format!("refs/remotes/{}", with_remote);
1584 if repo.repository().find_reference(&remote_ref).is_ok() {
1585 with_remote
1586 } else {
1587 anyhow::bail!(
1588 "Remote branch '{}' does not exist (also tried '{}')",
1589 branch,
1590 with_remote
1591 );
1592 }
1593 }
1594 }
1595 None => {
1596 let main_branch = &primary_remote.main_branch;
1598 if main_branch == "unknown" {
1599 anyhow::bail!(
1600 "Could not determine main branch for remote '{}'",
1601 primary_remote.name
1602 );
1603 }
1604
1605 let remote_main = format!("{}/{}", primary_remote.name, main_branch);
1606
1607 let remote_ref = format!("refs/remotes/{}", remote_main);
1609 if repo.repository().find_reference(&remote_ref).is_err() {
1610 anyhow::bail!(
1611 "Remote main branch '{}' does not exist. Try running 'git fetch' first.",
1612 remote_main
1613 );
1614 }
1615
1616 remote_main
1617 }
1618 };
1619
1620 let commit_range = format!("{}..HEAD", base_branch);
1622
1623 let wd_status = repo.get_working_directory_status()?;
1625 let working_directory = WorkingDirectoryInfo {
1626 clean: wd_status.clean,
1627 untracked_changes: wd_status
1628 .untracked_changes
1629 .into_iter()
1630 .map(|fs| FileStatusInfo {
1631 status: fs.status,
1632 file: fs.file,
1633 })
1634 .collect(),
1635 };
1636
1637 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1639
1640 let commits = repo.get_commits_in_range(&commit_range)?;
1642
1643 let pr_template_result = InfoCommand::read_pr_template().ok();
1645 let (pr_template, pr_template_location) = match pr_template_result {
1646 Some((content, location)) => (Some(content), Some(location)),
1647 None => (None, None),
1648 };
1649
1650 let branch_prs = InfoCommand::get_branch_prs(¤t_branch)
1652 .ok()
1653 .filter(|prs| !prs.is_empty());
1654
1655 let versions = Some(VersionInfo {
1657 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1658 });
1659
1660 let ai_scratch_path =
1662 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
1663 let ai_info = AiInfo {
1664 scratch: ai_scratch_path.to_string_lossy().to_string(),
1665 };
1666
1667 let mut repo_view = RepositoryView {
1669 versions,
1670 explanation: FieldExplanation::default(),
1671 working_directory,
1672 remotes,
1673 ai: ai_info,
1674 branch_info: Some(BranchInfo {
1675 branch: current_branch,
1676 }),
1677 pr_template,
1678 pr_template_location,
1679 branch_prs,
1680 commits,
1681 };
1682
1683 repo_view.update_field_presence();
1685
1686 Ok(repo_view)
1687 }
1688
1689 fn validate_branch_state(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
1691 if !repo_view.working_directory.clean {
1693 anyhow::bail!(
1694 "Working directory has uncommitted changes. Please commit or stash your changes before creating a PR."
1695 );
1696 }
1697
1698 if !repo_view.working_directory.untracked_changes.is_empty() {
1700 let file_list: Vec<&str> = repo_view
1701 .working_directory
1702 .untracked_changes
1703 .iter()
1704 .map(|f| f.file.as_str())
1705 .collect();
1706 anyhow::bail!(
1707 "Working directory has untracked changes: {}. Please commit or stash your changes before creating a PR.",
1708 file_list.join(", ")
1709 );
1710 }
1711
1712 if repo_view.commits.is_empty() {
1714 anyhow::bail!("No commits found to create PR from. Make sure you have commits that are not in the base branch.");
1715 }
1716
1717 if let Some(existing_prs) = &repo_view.branch_prs {
1719 if !existing_prs.is_empty() {
1720 let pr_info: Vec<String> = existing_prs
1721 .iter()
1722 .map(|pr| format!("#{} ({})", pr.number, pr.state))
1723 .collect();
1724
1725 println!(
1726 "📋 Existing PR(s) found for this branch: {}",
1727 pr_info.join(", ")
1728 );
1729 }
1731 }
1732
1733 Ok(())
1734 }
1735
1736 async fn show_context_information(
1738 &self,
1739 _repo_view: &crate::data::RepositoryView,
1740 ) -> Result<()> {
1741 Ok(())
1746 }
1747
1748 fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
1750 let base_branch = match self.base.as_ref() {
1752 Some(branch) => {
1753 let primary_remote_name = repo_view
1756 .remotes
1757 .iter()
1758 .find(|r| r.name == "origin")
1759 .or_else(|| repo_view.remotes.first())
1760 .map(|r| r.name.as_str())
1761 .unwrap_or("origin");
1762 if branch.starts_with(&format!("{}/", primary_remote_name)) {
1764 branch.clone()
1765 } else {
1766 format!("{}/{}", primary_remote_name, branch)
1767 }
1768 }
1769 None => {
1770 repo_view
1772 .remotes
1773 .iter()
1774 .find(|r| r.name == "origin")
1775 .or_else(|| repo_view.remotes.first())
1776 .map(|r| format!("{}/{}", r.name, r.main_branch))
1777 .unwrap_or_else(|| "unknown".to_string())
1778 }
1779 };
1780
1781 let commit_range = format!("{}..HEAD", base_branch);
1782 let commit_count = repo_view.commits.len();
1783
1784 let current_branch = repo_view
1786 .branch_info
1787 .as_ref()
1788 .map(|bi| bi.branch.as_str())
1789 .unwrap_or("unknown");
1790
1791 println!("📊 Branch Analysis:");
1792 println!(" 🌿 Current branch: {}", current_branch);
1793 println!(" 📏 Commit range: {}", commit_range);
1794 println!(" 📝 Commits found: {} commits", commit_count);
1795 println!();
1796
1797 Ok(())
1798 }
1799
1800 async fn collect_context(
1802 &self,
1803 repo_view: &crate::data::RepositoryView,
1804 ) -> Result<crate::data::context::CommitContext> {
1805 use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
1806 use crate::data::context::CommitContext;
1807 use crate::git::GitRepository;
1808
1809 let mut context = CommitContext::new();
1810
1811 let context_dir = std::path::PathBuf::from(".omni-dev");
1813
1814 let repo_root = std::path::PathBuf::from(".");
1816 let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
1817 match discovery.discover() {
1818 Ok(project_context) => {
1819 context.project = project_context;
1820 }
1821 Err(_e) => {
1822 context.project = Default::default();
1823 }
1824 }
1825
1826 let repo = GitRepository::open()?;
1828 let current_branch = repo
1829 .get_current_branch()
1830 .unwrap_or_else(|_| "HEAD".to_string());
1831 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
1832
1833 if !repo_view.commits.is_empty() {
1835 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
1836 }
1837
1838 Ok(context)
1839 }
1840
1841 fn show_guidance_files_status(
1843 &self,
1844 project_context: &crate::data::context::ProjectContext,
1845 ) -> Result<()> {
1846 let context_dir = std::path::PathBuf::from(".omni-dev");
1847
1848 println!("📋 Project guidance files status:");
1849
1850 let pr_guidelines_found = project_context.pr_guidelines.is_some();
1852 let pr_guidelines_source = if pr_guidelines_found {
1853 let local_path = context_dir.join("local").join("pr-guidelines.md");
1854 let project_path = context_dir.join("pr-guidelines.md");
1855 let home_path = dirs::home_dir()
1856 .map(|h| h.join(".omni-dev").join("pr-guidelines.md"))
1857 .unwrap_or_default();
1858
1859 if local_path.exists() {
1860 format!("✅ Local override: {}", local_path.display())
1861 } else if project_path.exists() {
1862 format!("✅ Project: {}", project_path.display())
1863 } else if home_path.exists() {
1864 format!("✅ Global: {}", home_path.display())
1865 } else {
1866 "✅ (source unknown)".to_string()
1867 }
1868 } else {
1869 "❌ None found".to_string()
1870 };
1871 println!(" 🔀 PR guidelines: {}", pr_guidelines_source);
1872
1873 let scopes_count = project_context.valid_scopes.len();
1875 let scopes_source = if scopes_count > 0 {
1876 let local_path = context_dir.join("local").join("scopes.yaml");
1877 let project_path = context_dir.join("scopes.yaml");
1878 let home_path = dirs::home_dir()
1879 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
1880 .unwrap_or_default();
1881
1882 let source = if local_path.exists() {
1883 format!("Local override: {}", local_path.display())
1884 } else if project_path.exists() {
1885 format!("Project: {}", project_path.display())
1886 } else if home_path.exists() {
1887 format!("Global: {}", home_path.display())
1888 } else {
1889 "(source unknown + ecosystem defaults)".to_string()
1890 };
1891 format!("✅ {} ({} scopes)", source, scopes_count)
1892 } else {
1893 "❌ None found".to_string()
1894 };
1895 println!(" 🎯 Valid scopes: {}", scopes_source);
1896
1897 let pr_template_path = std::path::Path::new(".github/pull_request_template.md");
1899 let pr_template_status = if pr_template_path.exists() {
1900 format!("✅ Project: {}", pr_template_path.display())
1901 } else {
1902 "❌ None found".to_string()
1903 };
1904 println!(" 📋 PR template: {}", pr_template_status);
1905
1906 println!();
1907 Ok(())
1908 }
1909
1910 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
1912 use crate::data::context::{VerbosityLevel, WorkPattern};
1913
1914 println!("🔍 Context Analysis:");
1915
1916 if !context.project.valid_scopes.is_empty() {
1918 let scope_names: Vec<&str> = context
1919 .project
1920 .valid_scopes
1921 .iter()
1922 .map(|s| s.name.as_str())
1923 .collect();
1924 println!(" 📁 Valid scopes: {}", scope_names.join(", "));
1925 }
1926
1927 if context.branch.is_feature_branch {
1929 println!(
1930 " 🌿 Branch: {} ({})",
1931 context.branch.description, context.branch.work_type
1932 );
1933 if let Some(ref ticket) = context.branch.ticket_id {
1934 println!(" 🎫 Ticket: {}", ticket);
1935 }
1936 }
1937
1938 match context.range.work_pattern {
1940 WorkPattern::Sequential => println!(" 🔄 Pattern: Sequential development"),
1941 WorkPattern::Refactoring => println!(" 🧹 Pattern: Refactoring work"),
1942 WorkPattern::BugHunt => println!(" 🐛 Pattern: Bug investigation"),
1943 WorkPattern::Documentation => println!(" 📖 Pattern: Documentation updates"),
1944 WorkPattern::Configuration => println!(" ⚙️ Pattern: Configuration changes"),
1945 WorkPattern::Unknown => {}
1946 }
1947
1948 match context.suggested_verbosity() {
1950 VerbosityLevel::Comprehensive => {
1951 println!(" 📝 Detail level: Comprehensive (significant changes detected)")
1952 }
1953 VerbosityLevel::Detailed => println!(" 📝 Detail level: Detailed"),
1954 VerbosityLevel::Concise => println!(" 📝 Detail level: Concise"),
1955 }
1956
1957 println!();
1958 Ok(())
1959 }
1960
1961 async fn generate_pr_content_with_client_internal(
1963 &self,
1964 repo_view: &crate::data::RepositoryView,
1965 claude_client: crate::claude::client::ClaudeClient,
1966 ) -> Result<(PrContent, crate::claude::client::ClaudeClient)> {
1967 use tracing::debug;
1968
1969 let pr_template = match &repo_view.pr_template {
1971 Some(template) => template.clone(),
1972 None => self.get_default_pr_template(),
1973 };
1974
1975 debug!(
1976 pr_template_length = pr_template.len(),
1977 pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
1978 "Using PR template for generation"
1979 );
1980
1981 println!("🤖 Generating AI-powered PR description...");
1982
1983 debug!("Collecting context for PR generation");
1985 let context = self.collect_context(repo_view).await?;
1986 debug!("Context collection completed");
1987
1988 debug!("About to call Claude AI for PR content generation");
1990 match claude_client
1991 .generate_pr_content_with_context(repo_view, &pr_template, &context)
1992 .await
1993 {
1994 Ok(pr_content) => {
1995 debug!(
1996 ai_generated_title = %pr_content.title,
1997 ai_generated_description_length = pr_content.description.len(),
1998 ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1999 "AI successfully generated PR content"
2000 );
2001 Ok((pr_content, claude_client))
2002 }
2003 Err(e) => {
2004 debug!(error = %e, "AI PR generation failed, falling back to basic description");
2005 let mut description = pr_template;
2007 self.enhance_description_with_commits(&mut description, repo_view)?;
2008
2009 let title = self.generate_title_from_commits(repo_view);
2011
2012 debug!(
2013 fallback_title = %title,
2014 fallback_description_length = description.len(),
2015 "Created fallback PR content"
2016 );
2017
2018 Ok((PrContent { title, description }, claude_client))
2019 }
2020 }
2021 }
2022
2023 fn get_default_pr_template(&self) -> String {
2025 r#"# Pull Request
2026
2027## Description
2028<!-- Provide a brief description of what this PR does -->
2029
2030## Type of Change
2031<!-- Mark the relevant option with an "x" -->
2032- [ ] Bug fix (non-breaking change which fixes an issue)
2033- [ ] New feature (non-breaking change which adds functionality)
2034- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
2035- [ ] Documentation update
2036- [ ] Refactoring (no functional changes)
2037- [ ] Performance improvement
2038- [ ] Test coverage improvement
2039
2040## Changes Made
2041<!-- List the specific changes made in this PR -->
2042-
2043-
2044-
2045
2046## Testing
2047- [ ] All existing tests pass
2048- [ ] New tests added for new functionality
2049- [ ] Manual testing performed
2050
2051## Additional Notes
2052<!-- Add any additional notes for reviewers -->
2053"#.to_string()
2054 }
2055
2056 fn enhance_description_with_commits(
2058 &self,
2059 description: &mut String,
2060 repo_view: &crate::data::RepositoryView,
2061 ) -> Result<()> {
2062 if repo_view.commits.is_empty() {
2063 return Ok(());
2064 }
2065
2066 description.push_str("\n---\n");
2068 description.push_str("## 📝 Commit Summary\n");
2069 description
2070 .push_str("*This section was automatically generated based on commit analysis*\n\n");
2071
2072 let mut types_found = std::collections::HashSet::new();
2074 let mut scopes_found = std::collections::HashSet::new();
2075 let mut has_breaking_changes = false;
2076
2077 for commit in &repo_view.commits {
2078 let detected_type = &commit.analysis.detected_type;
2079 types_found.insert(detected_type.clone());
2080 if detected_type.contains("BREAKING")
2081 || commit.original_message.contains("BREAKING CHANGE")
2082 {
2083 has_breaking_changes = true;
2084 }
2085
2086 let detected_scope = &commit.analysis.detected_scope;
2087 if !detected_scope.is_empty() {
2088 scopes_found.insert(detected_scope.clone());
2089 }
2090 }
2091
2092 if let Some(feat_pos) = description.find("- [ ] New feature") {
2094 if types_found.contains("feat") {
2095 description.replace_range(feat_pos..feat_pos + 5, "- [x]");
2096 }
2097 }
2098 if let Some(fix_pos) = description.find("- [ ] Bug fix") {
2099 if types_found.contains("fix") {
2100 description.replace_range(fix_pos..fix_pos + 5, "- [x]");
2101 }
2102 }
2103 if let Some(docs_pos) = description.find("- [ ] Documentation update") {
2104 if types_found.contains("docs") {
2105 description.replace_range(docs_pos..docs_pos + 5, "- [x]");
2106 }
2107 }
2108 if let Some(refactor_pos) = description.find("- [ ] Refactoring") {
2109 if types_found.contains("refactor") {
2110 description.replace_range(refactor_pos..refactor_pos + 5, "- [x]");
2111 }
2112 }
2113 if let Some(breaking_pos) = description.find("- [ ] Breaking change") {
2114 if has_breaking_changes {
2115 description.replace_range(breaking_pos..breaking_pos + 5, "- [x]");
2116 }
2117 }
2118
2119 if !scopes_found.is_empty() {
2121 let scopes_list: Vec<_> = scopes_found.into_iter().collect();
2122 description.push_str(&format!(
2123 "**Affected areas:** {}\n\n",
2124 scopes_list.join(", ")
2125 ));
2126 }
2127
2128 description.push_str("### Commits in this PR:\n");
2130 for commit in &repo_view.commits {
2131 let short_hash = &commit.hash[..8];
2132 let first_line = commit.original_message.lines().next().unwrap_or("").trim();
2133 description.push_str(&format!("- `{}` {}\n", short_hash, first_line));
2134 }
2135
2136 let total_files: usize = repo_view
2138 .commits
2139 .iter()
2140 .map(|c| c.analysis.file_changes.total_files)
2141 .sum();
2142
2143 if total_files > 0 {
2144 description.push_str(&format!("\n**Files changed:** {} files\n", total_files));
2145 }
2146
2147 Ok(())
2148 }
2149
2150 fn handle_pr_file(
2152 &self,
2153 pr_file: &std::path::Path,
2154 repo_view: &crate::data::RepositoryView,
2155 ) -> Result<PrAction> {
2156 use std::io::{self, Write};
2157
2158 println!("\n📝 PR details generated.");
2159 println!("💾 Details saved to: {}", pr_file.display());
2160
2161 let is_draft = self.should_create_as_draft();
2163 let status_icon = if is_draft { "📋" } else { "✅" };
2164 let status_text = if is_draft {
2165 "draft"
2166 } else {
2167 "ready for review"
2168 };
2169 println!("{} PR will be created as: {}", status_icon, status_text);
2170 println!();
2171
2172 let has_existing_prs = repo_view
2174 .branch_prs
2175 .as_ref()
2176 .is_some_and(|prs| !prs.is_empty());
2177
2178 loop {
2179 if has_existing_prs {
2180 print!("❓ [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
2181 } else {
2182 print!(
2183 "❓ [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
2184 );
2185 }
2186 io::stdout().flush()?;
2187
2188 let mut input = String::new();
2189 io::stdin().read_line(&mut input)?;
2190
2191 match input.trim().to_lowercase().as_str() {
2192 "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
2193 "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
2194 "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
2195 "s" | "show" => {
2196 self.show_pr_file(pr_file)?;
2197 println!();
2198 }
2199 "e" | "edit" => {
2200 self.edit_pr_file(pr_file)?;
2201 println!();
2202 }
2203 "q" | "quit" => return Ok(PrAction::Cancel),
2204 _ => {
2205 if has_existing_prs {
2206 println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
2207 } else {
2208 println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
2209 }
2210 }
2211 }
2212 }
2213 }
2214
2215 fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
2217 use std::fs;
2218
2219 println!("\n📄 PR details file contents:");
2220 println!("─────────────────────────────");
2221
2222 let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
2223 println!("{}", contents);
2224 println!("─────────────────────────────");
2225
2226 Ok(())
2227 }
2228
2229 fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
2231 use std::env;
2232 use std::io::{self, Write};
2233 use std::process::Command;
2234
2235 let editor = env::var("OMNI_DEV_EDITOR")
2237 .or_else(|_| env::var("EDITOR"))
2238 .unwrap_or_else(|_| {
2239 println!(
2241 "🔧 Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
2242 );
2243 print!("Please enter the command to use as your editor: ");
2244 io::stdout().flush().expect("Failed to flush stdout");
2245
2246 let mut input = String::new();
2247 io::stdin()
2248 .read_line(&mut input)
2249 .expect("Failed to read user input");
2250 input.trim().to_string()
2251 });
2252
2253 if editor.is_empty() {
2254 println!("❌ No editor specified. Returning to menu.");
2255 return Ok(());
2256 }
2257
2258 println!("📝 Opening PR details file in editor: {}", editor);
2259
2260 let mut cmd_parts = editor.split_whitespace();
2262 let editor_cmd = cmd_parts.next().unwrap_or(&editor);
2263 let args: Vec<&str> = cmd_parts.collect();
2264
2265 let mut command = Command::new(editor_cmd);
2266 command.args(args);
2267 command.arg(pr_file.to_string_lossy().as_ref());
2268
2269 match command.status() {
2270 Ok(status) => {
2271 if status.success() {
2272 println!("✅ Editor session completed.");
2273 } else {
2274 println!(
2275 "⚠️ Editor exited with non-zero status: {:?}",
2276 status.code()
2277 );
2278 }
2279 }
2280 Err(e) => {
2281 println!("❌ Failed to execute editor '{}': {}", editor, e);
2282 println!(" Please check that the editor command is correct and available in your PATH.");
2283 }
2284 }
2285
2286 Ok(())
2287 }
2288
2289 fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
2291 if repo_view.commits.is_empty() {
2292 return "Pull Request".to_string();
2293 }
2294
2295 if repo_view.commits.len() == 1 {
2297 return repo_view.commits[0]
2298 .original_message
2299 .lines()
2300 .next()
2301 .unwrap_or("Pull Request")
2302 .trim()
2303 .to_string();
2304 }
2305
2306 let branch_name = repo_view
2308 .branch_info
2309 .as_ref()
2310 .map(|bi| bi.branch.as_str())
2311 .unwrap_or("feature");
2312
2313 let cleaned_branch = branch_name.replace(['/', '-', '_'], " ");
2314
2315 format!("feat: {}", cleaned_branch)
2316 }
2317
2318 fn create_github_pr(
2320 &self,
2321 repo_view: &crate::data::RepositoryView,
2322 title: &str,
2323 description: &str,
2324 is_draft: bool,
2325 new_base: Option<&str>,
2326 ) -> Result<()> {
2327 use std::process::Command;
2328
2329 let branch_name = repo_view
2331 .branch_info
2332 .as_ref()
2333 .map(|bi| &bi.branch)
2334 .context("Branch info not available")?;
2335
2336 let pr_status = if is_draft {
2337 "draft"
2338 } else {
2339 "ready for review"
2340 };
2341 println!("🚀 Creating pull request ({})...", pr_status);
2342 println!(" 📋 Title: {}", title);
2343 println!(" 🌿 Branch: {}", branch_name);
2344 if let Some(base) = new_base {
2345 println!(" 🎯 Base: {}", base);
2346 }
2347
2348 debug!("Opening git repository to check branch status");
2350 let git_repo =
2351 crate::git::GitRepository::open().context("Failed to open git repository")?;
2352
2353 debug!(
2354 "Checking if branch '{}' exists on remote 'origin'",
2355 branch_name
2356 );
2357 if !git_repo.branch_exists_on_remote(branch_name, "origin")? {
2358 println!("📤 Pushing branch to remote...");
2359 debug!(
2360 "Branch '{}' not found on remote, attempting to push",
2361 branch_name
2362 );
2363 git_repo
2364 .push_branch(branch_name, "origin")
2365 .context("Failed to push branch to remote")?;
2366 } else {
2367 debug!("Branch '{}' already exists on remote 'origin'", branch_name);
2368 }
2369
2370 debug!("Creating PR with gh CLI - title: '{}'", title);
2372 debug!("PR description length: {} characters", description.len());
2373 debug!("PR draft status: {}", is_draft);
2374 if let Some(base) = new_base {
2375 debug!("PR base branch: {}", base);
2376 }
2377
2378 let mut args = vec![
2379 "pr",
2380 "create",
2381 "--head",
2382 branch_name,
2383 "--title",
2384 title,
2385 "--body",
2386 description,
2387 ];
2388
2389 if let Some(base) = new_base {
2390 args.push("--base");
2391 args.push(base);
2392 }
2393
2394 if is_draft {
2395 args.push("--draft");
2396 }
2397
2398 let pr_result = Command::new("gh")
2399 .args(&args)
2400 .output()
2401 .context("Failed to create pull request")?;
2402
2403 if pr_result.status.success() {
2404 let pr_url = String::from_utf8_lossy(&pr_result.stdout);
2405 let pr_url = pr_url.trim();
2406 debug!("PR created successfully with URL: {}", pr_url);
2407 println!("🎉 Pull request created: {}", pr_url);
2408 } else {
2409 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
2410 error!("gh CLI failed to create PR: {}", error_msg);
2411 anyhow::bail!("Failed to create pull request: {}", error_msg);
2412 }
2413
2414 Ok(())
2415 }
2416
2417 fn update_github_pr(
2419 &self,
2420 repo_view: &crate::data::RepositoryView,
2421 title: &str,
2422 description: &str,
2423 new_base: Option<&str>,
2424 ) -> Result<()> {
2425 use std::io::{self, Write};
2426 use std::process::Command;
2427
2428 let existing_pr = repo_view
2430 .branch_prs
2431 .as_ref()
2432 .and_then(|prs| prs.first())
2433 .context("No existing PR found to update")?;
2434
2435 let pr_number = existing_pr.number;
2436 let current_base = &existing_pr.base;
2437
2438 println!("🚀 Updating pull request #{}...", pr_number);
2439 println!(" 📋 Title: {}", title);
2440
2441 let change_base = if let Some(base) = new_base {
2443 if !current_base.is_empty() && current_base != base {
2444 print!(
2445 " 🎯 Current base: {} → New base: {}. Change? [y/N]: ",
2446 current_base, base
2447 );
2448 io::stdout().flush()?;
2449
2450 let mut input = String::new();
2451 io::stdin().read_line(&mut input)?;
2452 let response = input.trim().to_lowercase();
2453 response == "y" || response == "yes"
2454 } else {
2455 false
2456 }
2457 } else {
2458 false
2459 };
2460
2461 debug!(
2462 pr_number = pr_number,
2463 title = %title,
2464 description_length = description.len(),
2465 description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
2466 change_base = change_base,
2467 "Updating GitHub PR with title and description"
2468 );
2469
2470 let pr_number_str = pr_number.to_string();
2472 let mut gh_args = vec![
2473 "pr",
2474 "edit",
2475 &pr_number_str,
2476 "--title",
2477 title,
2478 "--body",
2479 description,
2480 ];
2481
2482 if change_base {
2483 if let Some(base) = new_base {
2484 gh_args.push("--base");
2485 gh_args.push(base);
2486 }
2487 }
2488
2489 debug!(
2490 args = ?gh_args,
2491 "Executing gh command to update PR"
2492 );
2493
2494 let pr_result = Command::new("gh")
2495 .args(&gh_args)
2496 .output()
2497 .context("Failed to update pull request")?;
2498
2499 if pr_result.status.success() {
2500 println!("🎉 Pull request updated: {}", existing_pr.url);
2502 if change_base {
2503 if let Some(base) = new_base {
2504 println!(" 🎯 Base branch changed to: {}", base);
2505 }
2506 }
2507 } else {
2508 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
2509 anyhow::bail!("Failed to update pull request: {}", error_msg);
2510 }
2511
2512 Ok(())
2513 }
2514
2515 fn show_model_info_from_client(
2517 &self,
2518 client: &crate::claude::client::ClaudeClient,
2519 ) -> Result<()> {
2520 use crate::claude::model_config::get_model_registry;
2521
2522 println!("🤖 AI Model Configuration:");
2523
2524 let metadata = client.get_ai_client_metadata();
2526 let registry = get_model_registry();
2527
2528 if let Some(spec) = registry.get_model_spec(&metadata.model) {
2529 if metadata.model != spec.api_identifier {
2531 println!(
2532 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
2533 metadata.model, spec.api_identifier
2534 );
2535 } else {
2536 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
2537 }
2538
2539 println!(" 🏷️ Provider: {}", spec.provider);
2540 println!(" 📊 Generation: {}", spec.generation);
2541 println!(" ⭐ Tier: {} ({})", spec.tier, {
2542 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
2543 &tier_info.description
2544 } else {
2545 "No description available"
2546 }
2547 });
2548 println!(" 📤 Max output tokens: {}", spec.max_output_tokens);
2549 println!(" 📥 Input context: {}", spec.input_context);
2550
2551 if spec.legacy {
2552 println!(" ⚠️ Legacy model (consider upgrading to newer version)");
2553 }
2554 } else {
2555 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
2557 println!(" 🏷️ Provider: {}", metadata.provider);
2558 println!(" ⚠️ Model not found in registry, using client metadata:");
2559 println!(" 📤 Max output tokens: {}", metadata.max_response_length);
2560 println!(" 📥 Input context: {}", metadata.max_context_length);
2561 }
2562
2563 println!();
2564 Ok(())
2565 }
2566}
2567
2568impl CheckCommand {
2569 pub async fn execute(self) -> Result<()> {
2571 use crate::data::check::OutputFormat;
2572
2573 let output_format: OutputFormat = self.format.parse().unwrap_or(OutputFormat::Text);
2575
2576 if !self.quiet && output_format == OutputFormat::Text {
2577 println!("🔍 Checking commit messages against guidelines...");
2578 }
2579
2580 let repo_view = self.generate_repository_view().await?;
2582
2583 if repo_view.commits.is_empty() {
2585 eprintln!("error: no commits found in range");
2586 std::process::exit(3);
2587 }
2588
2589 if !self.quiet && output_format == OutputFormat::Text {
2590 println!("📊 Found {} commits to check", repo_view.commits.len());
2591 }
2592
2593 let guidelines = self.load_guidelines().await?;
2595
2596 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
2598
2599 if self.verbose && output_format == OutputFormat::Text {
2600 self.show_model_info(&claude_client)?;
2601 }
2602
2603 let report = if repo_view.commits.len() > self.batch_size {
2605 if !self.quiet && output_format == OutputFormat::Text {
2606 println!(
2607 "📦 Processing {} commits in batches of {}...",
2608 repo_view.commits.len(),
2609 self.batch_size
2610 );
2611 }
2612 self.check_with_batching(&claude_client, &repo_view, guidelines.as_deref())
2613 .await?
2614 } else {
2615 if !self.quiet && output_format == OutputFormat::Text {
2617 println!("🤖 Analyzing commits with AI...");
2618 }
2619 claude_client
2620 .check_commits(&repo_view, guidelines.as_deref(), !self.no_suggestions)
2621 .await?
2622 };
2623
2624 self.output_report(&report, output_format)?;
2626
2627 let exit_code = report.exit_code(self.strict);
2629 if exit_code != 0 {
2630 std::process::exit(exit_code);
2631 }
2632
2633 Ok(())
2634 }
2635
2636 async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
2638 use crate::data::{
2639 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
2640 WorkingDirectoryInfo,
2641 };
2642 use crate::git::{GitRepository, RemoteInfo};
2643 use crate::utils::ai_scratch;
2644
2645 let repo = GitRepository::open()
2647 .context("Failed to open git repository. Make sure you're in a git repository.")?;
2648
2649 let current_branch = repo
2651 .get_current_branch()
2652 .unwrap_or_else(|_| "HEAD".to_string());
2653
2654 let commit_range = match &self.commit_range {
2656 Some(range) => range.clone(),
2657 None => {
2658 let base = if repo.branch_exists("main")? {
2660 "main"
2661 } else if repo.branch_exists("master")? {
2662 "master"
2663 } else {
2664 "HEAD~5"
2665 };
2666 format!("{}..HEAD", base)
2667 }
2668 };
2669
2670 let wd_status = repo.get_working_directory_status()?;
2672 let working_directory = WorkingDirectoryInfo {
2673 clean: wd_status.clean,
2674 untracked_changes: wd_status
2675 .untracked_changes
2676 .into_iter()
2677 .map(|fs| FileStatusInfo {
2678 status: fs.status,
2679 file: fs.file,
2680 })
2681 .collect(),
2682 };
2683
2684 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
2686
2687 let commits = repo.get_commits_in_range(&commit_range)?;
2689
2690 let versions = Some(VersionInfo {
2692 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
2693 });
2694
2695 let ai_scratch_path =
2697 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
2698 let ai_info = AiInfo {
2699 scratch: ai_scratch_path.to_string_lossy().to_string(),
2700 };
2701
2702 let mut repo_view = RepositoryView {
2704 versions,
2705 explanation: FieldExplanation::default(),
2706 working_directory,
2707 remotes,
2708 ai: ai_info,
2709 branch_info: Some(BranchInfo {
2710 branch: current_branch,
2711 }),
2712 pr_template: None,
2713 pr_template_location: None,
2714 branch_prs: None,
2715 commits,
2716 };
2717
2718 repo_view.update_field_presence();
2720
2721 Ok(repo_view)
2722 }
2723
2724 async fn load_guidelines(&self) -> Result<Option<String>> {
2726 use std::fs;
2727
2728 if let Some(guidelines_path) = &self.guidelines {
2730 let content = fs::read_to_string(guidelines_path).with_context(|| {
2731 format!("Failed to read guidelines file: {:?}", guidelines_path)
2732 })?;
2733 return Ok(Some(content));
2734 }
2735
2736 let context_dir = self
2738 .context_dir
2739 .clone()
2740 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
2741
2742 let local_path = context_dir.join("local").join("commit-guidelines.md");
2744 if local_path.exists() {
2745 let content = fs::read_to_string(&local_path)
2746 .with_context(|| format!("Failed to read guidelines: {:?}", local_path))?;
2747 return Ok(Some(content));
2748 }
2749
2750 let project_path = context_dir.join("commit-guidelines.md");
2752 if project_path.exists() {
2753 let content = fs::read_to_string(&project_path)
2754 .with_context(|| format!("Failed to read guidelines: {:?}", project_path))?;
2755 return Ok(Some(content));
2756 }
2757
2758 if let Some(home) = dirs::home_dir() {
2760 let home_path = home.join(".omni-dev").join("commit-guidelines.md");
2761 if home_path.exists() {
2762 let content = fs::read_to_string(&home_path)
2763 .with_context(|| format!("Failed to read guidelines: {:?}", home_path))?;
2764 return Ok(Some(content));
2765 }
2766 }
2767
2768 Ok(None)
2770 }
2771
2772 async fn check_with_batching(
2774 &self,
2775 claude_client: &crate::claude::client::ClaudeClient,
2776 full_repo_view: &crate::data::RepositoryView,
2777 guidelines: Option<&str>,
2778 ) -> Result<crate::data::check::CheckReport> {
2779 use crate::data::check::{CheckReport, CommitCheckResult};
2780
2781 let commit_batches: Vec<_> = full_repo_view.commits.chunks(self.batch_size).collect();
2782 let total_batches = commit_batches.len();
2783 let mut all_results: Vec<CommitCheckResult> = Vec::new();
2784
2785 for (batch_num, commit_batch) in commit_batches.into_iter().enumerate() {
2786 if !self.quiet {
2787 println!(
2788 "🔄 Processing batch {}/{} ({} commits)...",
2789 batch_num + 1,
2790 total_batches,
2791 commit_batch.len()
2792 );
2793 }
2794
2795 let batch_repo_view = crate::data::RepositoryView {
2797 versions: full_repo_view.versions.clone(),
2798 explanation: full_repo_view.explanation.clone(),
2799 working_directory: full_repo_view.working_directory.clone(),
2800 remotes: full_repo_view.remotes.clone(),
2801 ai: full_repo_view.ai.clone(),
2802 branch_info: full_repo_view.branch_info.clone(),
2803 pr_template: full_repo_view.pr_template.clone(),
2804 pr_template_location: full_repo_view.pr_template_location.clone(),
2805 branch_prs: full_repo_view.branch_prs.clone(),
2806 commits: commit_batch.to_vec(),
2807 };
2808
2809 let batch_report = claude_client
2811 .check_commits(&batch_repo_view, guidelines, !self.no_suggestions)
2812 .await?;
2813
2814 all_results.extend(batch_report.commits);
2816
2817 if batch_num + 1 < total_batches {
2818 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
2820 }
2821 }
2822
2823 Ok(CheckReport::new(all_results))
2824 }
2825
2826 fn output_report(
2828 &self,
2829 report: &crate::data::check::CheckReport,
2830 format: crate::data::check::OutputFormat,
2831 ) -> Result<()> {
2832 use crate::data::check::OutputFormat;
2833
2834 match format {
2835 OutputFormat::Text => self.output_text_report(report),
2836 OutputFormat::Json => {
2837 let json = serde_json::to_string_pretty(report)
2838 .context("Failed to serialize report to JSON")?;
2839 println!("{}", json);
2840 Ok(())
2841 }
2842 OutputFormat::Yaml => {
2843 let yaml =
2844 crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
2845 println!("{}", yaml);
2846 Ok(())
2847 }
2848 }
2849 }
2850
2851 fn output_text_report(&self, report: &crate::data::check::CheckReport) -> Result<()> {
2853 use crate::data::check::IssueSeverity;
2854
2855 println!();
2856
2857 for result in &report.commits {
2858 if result.passes && !self.show_passing {
2860 continue;
2861 }
2862
2863 if self.quiet {
2865 let has_errors_or_warnings = result
2866 .issues
2867 .iter()
2868 .any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning));
2869 if !has_errors_or_warnings {
2870 continue;
2871 }
2872 }
2873
2874 let icon = if result.passes {
2876 "✅"
2877 } else if result
2878 .issues
2879 .iter()
2880 .any(|i| i.severity == IssueSeverity::Error)
2881 {
2882 "❌"
2883 } else {
2884 "⚠️ "
2885 };
2886
2887 let short_hash = if result.hash.len() > 7 {
2889 &result.hash[..7]
2890 } else {
2891 &result.hash
2892 };
2893
2894 println!("{} {} - \"{}\"", icon, short_hash, result.message);
2895
2896 for issue in &result.issues {
2898 if self.quiet && issue.severity == IssueSeverity::Info {
2900 continue;
2901 }
2902
2903 let severity_str = match issue.severity {
2904 IssueSeverity::Error => "\x1b[31mERROR\x1b[0m ",
2905 IssueSeverity::Warning => "\x1b[33mWARNING\x1b[0m",
2906 IssueSeverity::Info => "\x1b[36mINFO\x1b[0m ",
2907 };
2908
2909 println!(
2910 " {} [{}] {}",
2911 severity_str, issue.section, issue.explanation
2912 );
2913 }
2914
2915 if !self.quiet {
2917 if let Some(suggestion) = &result.suggestion {
2918 println!();
2919 println!(" Suggested message:");
2920 for line in suggestion.message.lines() {
2921 println!(" {}", line);
2922 }
2923 if self.verbose {
2924 println!();
2925 println!(" Why this is better:");
2926 for line in suggestion.explanation.lines() {
2927 println!(" {}", line);
2928 }
2929 }
2930 }
2931 }
2932
2933 println!();
2934 }
2935
2936 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
2938 println!("Summary: {} commits checked", report.summary.total_commits);
2939 println!(
2940 " {} errors, {} warnings",
2941 report.summary.error_count, report.summary.warning_count
2942 );
2943 println!(
2944 " {} passed, {} with issues",
2945 report.summary.passing_commits, report.summary.failing_commits
2946 );
2947
2948 Ok(())
2949 }
2950
2951 fn show_model_info(&self, client: &crate::claude::client::ClaudeClient) -> Result<()> {
2953 use crate::claude::model_config::get_model_registry;
2954
2955 println!("🤖 AI Model Configuration:");
2956
2957 let metadata = client.get_ai_client_metadata();
2958 let registry = get_model_registry();
2959
2960 if let Some(spec) = registry.get_model_spec(&metadata.model) {
2961 if metadata.model != spec.api_identifier {
2962 println!(
2963 " 📡 Model: {} → \x1b[33m{}\x1b[0m",
2964 metadata.model, spec.api_identifier
2965 );
2966 } else {
2967 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
2968 }
2969 println!(" 🏷️ Provider: {}", spec.provider);
2970 } else {
2971 println!(" 📡 Model: \x1b[33m{}\x1b[0m", metadata.model);
2972 println!(" 🏷️ Provider: {}", metadata.provider);
2973 }
2974
2975 println!();
2976 Ok(())
2977 }
2978}