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