1use anyhow::{Context, Result};
4use clap::{Parser, Subcommand};
5use tracing::{debug, error};
6
7#[derive(Parser)]
9pub struct GitCommand {
10 #[command(subcommand)]
12 pub command: GitSubcommands,
13}
14
15#[derive(Subcommand)]
17pub enum GitSubcommands {
18 Commit(CommitCommand),
20 Branch(BranchCommand),
22}
23
24#[derive(Parser)]
26pub struct CommitCommand {
27 #[command(subcommand)]
29 pub command: CommitSubcommands,
30}
31
32#[derive(Subcommand)]
34pub enum CommitSubcommands {
35 Message(MessageCommand),
37}
38
39#[derive(Parser)]
41pub struct MessageCommand {
42 #[command(subcommand)]
44 pub command: MessageSubcommands,
45}
46
47#[derive(Subcommand)]
49pub enum MessageSubcommands {
50 View(ViewCommand),
52 Amend(AmendCommand),
54 Twiddle(TwiddleCommand),
56}
57
58#[derive(Parser)]
60pub struct ViewCommand {
61 #[arg(value_name = "COMMIT_RANGE")]
63 pub commit_range: Option<String>,
64}
65
66#[derive(Parser)]
68pub struct AmendCommand {
69 #[arg(value_name = "YAML_FILE")]
71 pub yaml_file: String,
72}
73
74#[derive(Parser)]
76pub struct TwiddleCommand {
77 #[arg(value_name = "COMMIT_RANGE")]
79 pub commit_range: Option<String>,
80
81 #[arg(long)]
83 pub model: Option<String>,
84
85 #[arg(long)]
87 pub auto_apply: bool,
88
89 #[arg(long, value_name = "FILE")]
91 pub save_only: Option<String>,
92
93 #[arg(long, default_value = "true")]
95 pub use_context: bool,
96
97 #[arg(long)]
99 pub context_dir: Option<std::path::PathBuf>,
100
101 #[arg(long)]
103 pub work_context: Option<String>,
104
105 #[arg(long)]
107 pub branch_context: Option<String>,
108
109 #[arg(long)]
111 pub no_context: bool,
112
113 #[arg(long, default_value = "4")]
115 pub batch_size: usize,
116
117 #[arg(long)]
119 pub no_ai: bool,
120}
121
122#[derive(Parser)]
124pub struct BranchCommand {
125 #[command(subcommand)]
127 pub command: BranchSubcommands,
128}
129
130#[derive(Subcommand)]
132pub enum BranchSubcommands {
133 Info(InfoCommand),
135 Create(CreateCommand),
137}
138
139#[derive(Parser)]
141pub struct InfoCommand {
142 #[arg(value_name = "BASE_BRANCH")]
144 pub base_branch: Option<String>,
145}
146
147#[derive(Parser)]
149pub struct CreateCommand {
150 #[command(subcommand)]
152 pub command: CreateSubcommands,
153}
154
155#[derive(Subcommand)]
157pub enum CreateSubcommands {
158 Pr(CreatePrCommand),
160}
161
162#[derive(Parser)]
164pub struct CreatePrCommand {
165 #[arg(value_name = "BASE_BRANCH")]
167 pub base_branch: Option<String>,
168
169 #[arg(long)]
171 pub auto_apply: bool,
172
173 #[arg(long, value_name = "FILE")]
175 pub save_only: Option<String>,
176
177 #[arg(long, conflicts_with = "draft")]
179 pub ready: bool,
180
181 #[arg(long, conflicts_with = "ready")]
183 pub draft: bool,
184}
185
186impl GitCommand {
187 pub fn execute(self) -> Result<()> {
189 match self.command {
190 GitSubcommands::Commit(commit_cmd) => commit_cmd.execute(),
191 GitSubcommands::Branch(branch_cmd) => branch_cmd.execute(),
192 }
193 }
194}
195
196impl CommitCommand {
197 pub fn execute(self) -> Result<()> {
199 match self.command {
200 CommitSubcommands::Message(message_cmd) => message_cmd.execute(),
201 }
202 }
203}
204
205impl MessageCommand {
206 pub fn execute(self) -> Result<()> {
208 match self.command {
209 MessageSubcommands::View(view_cmd) => view_cmd.execute(),
210 MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(),
211 MessageSubcommands::Twiddle(twiddle_cmd) => {
212 let rt =
214 tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
215 rt.block_on(twiddle_cmd.execute())
216 }
217 }
218 }
219}
220
221impl ViewCommand {
222 pub fn execute(self) -> Result<()> {
224 use crate::data::{
225 AiInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
226 WorkingDirectoryInfo,
227 };
228 use crate::git::{GitRepository, RemoteInfo};
229 use crate::utils::ai_scratch;
230
231 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD");
232
233 let repo = GitRepository::open()
235 .context("Failed to open git repository. Make sure you're in a git repository.")?;
236
237 let wd_status = repo.get_working_directory_status()?;
239 let working_directory = WorkingDirectoryInfo {
240 clean: wd_status.clean,
241 untracked_changes: wd_status
242 .untracked_changes
243 .into_iter()
244 .map(|fs| FileStatusInfo {
245 status: fs.status,
246 file: fs.file,
247 })
248 .collect(),
249 };
250
251 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
253
254 let commits = repo.get_commits_in_range(commit_range)?;
256
257 let versions = Some(VersionInfo {
259 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
260 });
261
262 let ai_scratch_path =
264 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
265 let ai_info = AiInfo {
266 scratch: ai_scratch_path.to_string_lossy().to_string(),
267 };
268
269 let mut repo_view = RepositoryView {
271 versions,
272 explanation: FieldExplanation::default(),
273 working_directory,
274 remotes,
275 ai: ai_info,
276 branch_info: None,
277 pr_template: None,
278 pr_template_location: None,
279 branch_prs: None,
280 commits,
281 };
282
283 repo_view.update_field_presence();
285
286 let yaml_output = crate::data::to_yaml(&repo_view)?;
288 println!("{}", yaml_output);
289
290 Ok(())
291 }
292}
293
294impl AmendCommand {
295 pub fn execute(self) -> Result<()> {
297 use crate::git::AmendmentHandler;
298
299 println!("๐ Starting commit amendment process...");
300 println!("๐ Loading amendments from: {}", self.yaml_file);
301
302 let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
304
305 handler
306 .apply_amendments(&self.yaml_file)
307 .context("Failed to apply amendments")?;
308
309 Ok(())
310 }
311}
312
313impl TwiddleCommand {
314 pub async fn execute(self) -> Result<()> {
316 if self.no_ai {
318 return self.execute_no_ai().await;
319 }
320
321 let use_contextual = self.use_context && !self.no_context;
323
324 if use_contextual {
325 println!(
326 "๐ช Starting AI-powered commit message improvement with contextual intelligence..."
327 );
328 } else {
329 println!("๐ช Starting AI-powered commit message improvement...");
330 }
331
332 let full_repo_view = self.generate_repository_view().await?;
334
335 if full_repo_view.commits.len() > self.batch_size {
337 println!(
338 "๐ฆ Processing {} commits in batches of {} to ensure reliable analysis...",
339 full_repo_view.commits.len(),
340 self.batch_size
341 );
342 return self
343 .execute_with_batching(use_contextual, full_repo_view)
344 .await;
345 }
346
347 let context = if use_contextual {
349 Some(self.collect_context(&full_repo_view).await?)
350 } else {
351 None
352 };
353
354 if let Some(ref ctx) = context {
356 self.show_context_summary(ctx)?;
357 }
358
359 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
361
362 self.show_model_info_from_client(&claude_client)?;
364
365 if use_contextual && context.is_some() {
367 println!("๐ค Analyzing commits with enhanced contextual intelligence...");
368 } else {
369 println!("๐ค Analyzing commits with Claude AI...");
370 }
371
372 let amendments = if let Some(ctx) = context {
373 claude_client
374 .generate_contextual_amendments(&full_repo_view, &ctx)
375 .await?
376 } else {
377 claude_client.generate_amendments(&full_repo_view).await?
378 };
379
380 if let Some(save_path) = self.save_only {
382 amendments.save_to_file(save_path)?;
383 println!("๐พ Amendments saved to file");
384 return Ok(());
385 }
386
387 if !amendments.amendments.is_empty() {
389 let temp_dir = tempfile::tempdir()?;
391 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
392 amendments.save_to_file(&amendments_file)?;
393
394 if !self.auto_apply && !self.handle_amendments_file(&amendments_file, &amendments)? {
396 println!("โ Amendment cancelled by user");
397 return Ok(());
398 }
399
400 self.apply_amendments_from_file(&amendments_file).await?;
402 println!("โ
Commit messages improved successfully!");
403 } else {
404 println!("โจ No commits found to process!");
405 }
406
407 Ok(())
408 }
409
410 async fn execute_with_batching(
412 &self,
413 use_contextual: bool,
414 full_repo_view: crate::data::RepositoryView,
415 ) -> Result<()> {
416 use crate::data::amendments::AmendmentFile;
417
418 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
420
421 self.show_model_info_from_client(&claude_client)?;
423
424 let commit_batches: Vec<_> = full_repo_view.commits.chunks(self.batch_size).collect();
426
427 let total_batches = commit_batches.len();
428 let mut all_amendments = AmendmentFile {
429 amendments: Vec::new(),
430 };
431
432 println!("๐ Processing {} batches...", total_batches);
433
434 for (batch_num, commit_batch) in commit_batches.into_iter().enumerate() {
435 println!(
436 "๐ Processing batch {}/{} ({} commits)...",
437 batch_num + 1,
438 total_batches,
439 commit_batch.len()
440 );
441
442 let batch_repo_view = crate::data::RepositoryView {
444 versions: full_repo_view.versions.clone(),
445 explanation: full_repo_view.explanation.clone(),
446 working_directory: full_repo_view.working_directory.clone(),
447 remotes: full_repo_view.remotes.clone(),
448 ai: full_repo_view.ai.clone(),
449 branch_info: full_repo_view.branch_info.clone(),
450 pr_template: full_repo_view.pr_template.clone(),
451 pr_template_location: full_repo_view.pr_template_location.clone(),
452 branch_prs: full_repo_view.branch_prs.clone(),
453 commits: commit_batch.to_vec(),
454 };
455
456 let batch_context = if use_contextual {
458 Some(self.collect_context(&batch_repo_view).await?)
459 } else {
460 None
461 };
462
463 let batch_amendments = if let Some(ctx) = batch_context {
465 claude_client
466 .generate_contextual_amendments(&batch_repo_view, &ctx)
467 .await?
468 } else {
469 claude_client.generate_amendments(&batch_repo_view).await?
470 };
471
472 all_amendments
474 .amendments
475 .extend(batch_amendments.amendments);
476
477 if batch_num + 1 < total_batches {
478 println!(" โ
Batch {}/{} completed", batch_num + 1, total_batches);
479 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
481 }
482 }
483
484 println!(
485 "โ
All batches completed! Found {} commits to improve.",
486 all_amendments.amendments.len()
487 );
488
489 if let Some(save_path) = &self.save_only {
491 all_amendments.save_to_file(save_path)?;
492 println!("๐พ Amendments saved to file");
493 return Ok(());
494 }
495
496 if !all_amendments.amendments.is_empty() {
498 let temp_dir = tempfile::tempdir()?;
500 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
501 all_amendments.save_to_file(&amendments_file)?;
502
503 if !self.auto_apply
505 && !self.handle_amendments_file(&amendments_file, &all_amendments)?
506 {
507 println!("โ Amendment cancelled by user");
508 return Ok(());
509 }
510
511 self.apply_amendments_from_file(&amendments_file).await?;
513 println!("โ
Commit messages improved successfully!");
514 } else {
515 println!("โจ No commits found to process!");
516 }
517
518 Ok(())
519 }
520
521 async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
523 use crate::data::{
524 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
525 WorkingDirectoryInfo,
526 };
527 use crate::git::{GitRepository, RemoteInfo};
528 use crate::utils::ai_scratch;
529
530 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
531
532 let repo = GitRepository::open()
534 .context("Failed to open git repository. Make sure you're in a git repository.")?;
535
536 let current_branch = repo
538 .get_current_branch()
539 .unwrap_or_else(|_| "HEAD".to_string());
540
541 let wd_status = repo.get_working_directory_status()?;
543 let working_directory = WorkingDirectoryInfo {
544 clean: wd_status.clean,
545 untracked_changes: wd_status
546 .untracked_changes
547 .into_iter()
548 .map(|fs| FileStatusInfo {
549 status: fs.status,
550 file: fs.file,
551 })
552 .collect(),
553 };
554
555 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
557
558 let commits = repo.get_commits_in_range(commit_range)?;
560
561 let versions = Some(VersionInfo {
563 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
564 });
565
566 let ai_scratch_path =
568 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
569 let ai_info = AiInfo {
570 scratch: ai_scratch_path.to_string_lossy().to_string(),
571 };
572
573 let mut repo_view = RepositoryView {
575 versions,
576 explanation: FieldExplanation::default(),
577 working_directory,
578 remotes,
579 ai: ai_info,
580 branch_info: Some(BranchInfo {
581 branch: current_branch,
582 }),
583 pr_template: None,
584 pr_template_location: None,
585 branch_prs: None,
586 commits,
587 };
588
589 repo_view.update_field_presence();
591
592 Ok(repo_view)
593 }
594
595 fn handle_amendments_file(
597 &self,
598 amendments_file: &std::path::Path,
599 amendments: &crate::data::amendments::AmendmentFile,
600 ) -> Result<bool> {
601 use std::io::{self, Write};
602
603 println!(
604 "\n๐ Found {} commits that could be improved.",
605 amendments.amendments.len()
606 );
607 println!("๐พ Amendments saved to: {}", amendments_file.display());
608 println!();
609
610 loop {
611 print!("โ [A]pply amendments, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] ");
612 io::stdout().flush()?;
613
614 let mut input = String::new();
615 io::stdin().read_line(&mut input)?;
616
617 match input.trim().to_lowercase().as_str() {
618 "a" | "apply" | "" => return Ok(true),
619 "s" | "show" => {
620 self.show_amendments_file(amendments_file)?;
621 println!();
622 }
623 "e" | "edit" => {
624 self.edit_amendments_file(amendments_file)?;
625 println!();
626 }
627 "q" | "quit" => return Ok(false),
628 _ => {
629 println!(
630 "Invalid choice. Please enter 'a' to apply, 's' to show, 'e' to edit, or 'q' to quit."
631 );
632 }
633 }
634 }
635 }
636
637 fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
639 use std::fs;
640
641 println!("\n๐ Amendments file contents:");
642 println!("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ");
643
644 let contents =
645 fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
646
647 println!("{}", contents);
648 println!("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ");
649
650 Ok(())
651 }
652
653 fn edit_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
655 use std::env;
656 use std::io::{self, Write};
657 use std::process::Command;
658
659 let editor = env::var("OMNI_DEV_EDITOR")
661 .or_else(|_| env::var("EDITOR"))
662 .unwrap_or_else(|_| {
663 println!(
665 "๐ง Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
666 );
667 print!("Please enter the command to use as your editor: ");
668 io::stdout().flush().expect("Failed to flush stdout");
669
670 let mut input = String::new();
671 io::stdin()
672 .read_line(&mut input)
673 .expect("Failed to read user input");
674 input.trim().to_string()
675 });
676
677 if editor.is_empty() {
678 println!("โ No editor specified. Returning to menu.");
679 return Ok(());
680 }
681
682 println!("๐ Opening amendments file in editor: {}", editor);
683
684 let mut cmd_parts = editor.split_whitespace();
686 let editor_cmd = cmd_parts.next().unwrap_or(&editor);
687 let args: Vec<&str> = cmd_parts.collect();
688
689 let mut command = Command::new(editor_cmd);
690 command.args(args);
691 command.arg(amendments_file.to_string_lossy().as_ref());
692
693 match command.status() {
694 Ok(status) => {
695 if status.success() {
696 println!("โ
Editor session completed.");
697 } else {
698 println!(
699 "โ ๏ธ Editor exited with non-zero status: {:?}",
700 status.code()
701 );
702 }
703 }
704 Err(e) => {
705 println!("โ Failed to execute editor '{}': {}", editor, e);
706 println!(" Please check that the editor command is correct and available in your PATH.");
707 }
708 }
709
710 Ok(())
711 }
712
713 async fn apply_amendments_from_file(&self, amendments_file: &std::path::Path) -> Result<()> {
715 use crate::git::AmendmentHandler;
716
717 let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
719 handler
720 .apply_amendments(&amendments_file.to_string_lossy())
721 .context("Failed to apply amendments")?;
722
723 Ok(())
724 }
725
726 async fn collect_context(
728 &self,
729 repo_view: &crate::data::RepositoryView,
730 ) -> Result<crate::data::context::CommitContext> {
731 use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
732 use crate::data::context::CommitContext;
733
734 let mut context = CommitContext::new();
735
736 let context_dir = self
738 .context_dir
739 .as_ref()
740 .cloned()
741 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
742
743 let repo_root = std::path::PathBuf::from(".");
745 let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
746 debug!(context_dir = ?context_dir, "Using context directory");
747 match discovery.discover() {
748 Ok(project_context) => {
749 debug!("Discovery successful");
750
751 self.show_guidance_files_status(&project_context, &context_dir)?;
753
754 context.project = project_context;
755 }
756 Err(e) => {
757 debug!(error = %e, "Discovery failed");
758 context.project = Default::default();
759 }
760 }
761
762 if let Some(branch_info) = &repo_view.branch_info {
764 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
765 } else {
766 use crate::git::GitRepository;
768 let repo = GitRepository::open()?;
769 let current_branch = repo
770 .get_current_branch()
771 .unwrap_or_else(|_| "HEAD".to_string());
772 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
773 }
774
775 if !repo_view.commits.is_empty() {
777 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
778 }
779
780 if let Some(ref work_ctx) = self.work_context {
782 context.user_provided = Some(work_ctx.clone());
783 }
784
785 if let Some(ref branch_ctx) = self.branch_context {
786 context.branch.description = branch_ctx.clone();
787 }
788
789 Ok(context)
790 }
791
792 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
794 use crate::data::context::{VerbosityLevel, WorkPattern};
795
796 println!("๐ Context Analysis:");
797
798 if !context.project.valid_scopes.is_empty() {
800 let scope_names: Vec<&str> = context
801 .project
802 .valid_scopes
803 .iter()
804 .map(|s| s.name.as_str())
805 .collect();
806 println!(" ๐ Valid scopes: {}", scope_names.join(", "));
807 }
808
809 if context.branch.is_feature_branch {
811 println!(
812 " ๐ฟ Branch: {} ({})",
813 context.branch.description, context.branch.work_type
814 );
815 if let Some(ref ticket) = context.branch.ticket_id {
816 println!(" ๐ซ Ticket: {}", ticket);
817 }
818 }
819
820 match context.range.work_pattern {
822 WorkPattern::Sequential => println!(" ๐ Pattern: Sequential development"),
823 WorkPattern::Refactoring => println!(" ๐งน Pattern: Refactoring work"),
824 WorkPattern::BugHunt => println!(" ๐ Pattern: Bug investigation"),
825 WorkPattern::Documentation => println!(" ๐ Pattern: Documentation updates"),
826 WorkPattern::Configuration => println!(" โ๏ธ Pattern: Configuration changes"),
827 WorkPattern::Unknown => {}
828 }
829
830 match context.suggested_verbosity() {
832 VerbosityLevel::Comprehensive => {
833 println!(" ๐ Detail level: Comprehensive (significant changes detected)")
834 }
835 VerbosityLevel::Detailed => println!(" ๐ Detail level: Detailed"),
836 VerbosityLevel::Concise => println!(" ๐ Detail level: Concise"),
837 }
838
839 if let Some(ref user_ctx) = context.user_provided {
841 println!(" ๐ค User context: {}", user_ctx);
842 }
843
844 println!();
845 Ok(())
846 }
847
848 fn show_model_info_from_client(
850 &self,
851 client: &crate::claude::client::ClaudeClient,
852 ) -> Result<()> {
853 use crate::claude::model_config::get_model_registry;
854
855 println!("๐ค AI Model Configuration:");
856
857 let metadata = client.get_ai_client_metadata();
859 let registry = get_model_registry();
860
861 if let Some(spec) = registry.get_model_spec(&metadata.model) {
862 if metadata.model != spec.api_identifier {
864 println!(
865 " ๐ก Model: {} โ \x1b[33m{}\x1b[0m",
866 metadata.model, spec.api_identifier
867 );
868 } else {
869 println!(" ๐ก Model: \x1b[33m{}\x1b[0m", metadata.model);
870 }
871
872 println!(" ๐ท๏ธ Provider: {}", spec.provider);
873 println!(" ๐ Generation: {}", spec.generation);
874 println!(" โญ Tier: {} ({})", spec.tier, {
875 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
876 &tier_info.description
877 } else {
878 "No description available"
879 }
880 });
881 println!(" ๐ค Max output tokens: {}", spec.max_output_tokens);
882 println!(" ๐ฅ Input context: {}", spec.input_context);
883
884 if spec.legacy {
885 println!(" โ ๏ธ Legacy model (consider upgrading to newer version)");
886 }
887 } else {
888 println!(" ๐ก Model: \x1b[33m{}\x1b[0m", metadata.model);
890 println!(" ๐ท๏ธ Provider: {}", metadata.provider);
891 println!(" โ ๏ธ Model not found in registry, using client metadata:");
892 println!(" ๐ค Max output tokens: {}", metadata.max_response_length);
893 println!(" ๐ฅ Input context: {}", metadata.max_context_length);
894 }
895
896 println!();
897 Ok(())
898 }
899
900 fn show_guidance_files_status(
902 &self,
903 project_context: &crate::data::context::ProjectContext,
904 context_dir: &std::path::Path,
905 ) -> Result<()> {
906 println!("๐ Project guidance files status:");
907
908 let guidelines_found = project_context.commit_guidelines.is_some();
910 let guidelines_source = if guidelines_found {
911 let local_path = context_dir.join("local").join("commit-guidelines.md");
912 let project_path = context_dir.join("commit-guidelines.md");
913 let home_path = dirs::home_dir()
914 .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
915 .unwrap_or_default();
916
917 if local_path.exists() {
918 format!("โ
Local override: {}", local_path.display())
919 } else if project_path.exists() {
920 format!("โ
Project: {}", project_path.display())
921 } else if home_path.exists() {
922 format!("โ
Global: {}", home_path.display())
923 } else {
924 "โ
(source unknown)".to_string()
925 }
926 } else {
927 "โ None found".to_string()
928 };
929 println!(" ๐ Commit guidelines: {}", guidelines_source);
930
931 let scopes_count = project_context.valid_scopes.len();
933 let scopes_source = if scopes_count > 0 {
934 let local_path = context_dir.join("local").join("scopes.yaml");
935 let project_path = context_dir.join("scopes.yaml");
936 let home_path = dirs::home_dir()
937 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
938 .unwrap_or_default();
939
940 let source = if local_path.exists() {
941 format!("Local override: {}", local_path.display())
942 } else if project_path.exists() {
943 format!("Project: {}", project_path.display())
944 } else if home_path.exists() {
945 format!("Global: {}", home_path.display())
946 } else {
947 "(source unknown + ecosystem defaults)".to_string()
948 };
949 format!("โ
{} ({} scopes)", source, scopes_count)
950 } else {
951 "โ None found".to_string()
952 };
953 println!(" ๐ฏ Valid scopes: {}", scopes_source);
954
955 println!();
956 Ok(())
957 }
958
959 async fn execute_no_ai(&self) -> Result<()> {
961 use crate::data::amendments::{Amendment, AmendmentFile};
962
963 println!("๐ Generating amendments YAML without AI processing...");
964
965 let repo_view = self.generate_repository_view().await?;
967
968 let amendments: Vec<Amendment> = repo_view
970 .commits
971 .iter()
972 .map(|commit| Amendment {
973 commit: commit.hash.clone(),
974 message: commit.original_message.clone(),
975 })
976 .collect();
977
978 let amendment_file = AmendmentFile { amendments };
979
980 if let Some(save_path) = &self.save_only {
982 amendment_file.save_to_file(save_path)?;
983 println!("๐พ Amendments saved to file");
984 return Ok(());
985 }
986
987 if !amendment_file.amendments.is_empty() {
989 let temp_dir = tempfile::tempdir()?;
991 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
992 amendment_file.save_to_file(&amendments_file)?;
993
994 if !self.auto_apply
996 && !self.handle_amendments_file(&amendments_file, &amendment_file)?
997 {
998 println!("โ Amendment cancelled by user");
999 return Ok(());
1000 }
1001
1002 self.apply_amendments_from_file(&amendments_file).await?;
1004 println!("โ
Commit messages applied successfully!");
1005 } else {
1006 println!("โจ No commits found to process!");
1007 }
1008
1009 Ok(())
1010 }
1011}
1012
1013impl BranchCommand {
1014 pub fn execute(self) -> Result<()> {
1016 match self.command {
1017 BranchSubcommands::Info(info_cmd) => info_cmd.execute(),
1018 BranchSubcommands::Create(create_cmd) => {
1019 let rt = tokio::runtime::Runtime::new()
1021 .context("Failed to create tokio runtime for PR creation")?;
1022 rt.block_on(create_cmd.execute())
1023 }
1024 }
1025 }
1026}
1027
1028impl InfoCommand {
1029 pub fn execute(self) -> Result<()> {
1031 use crate::data::{
1032 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
1033 WorkingDirectoryInfo,
1034 };
1035 use crate::git::{GitRepository, RemoteInfo};
1036 use crate::utils::ai_scratch;
1037
1038 let repo = GitRepository::open()
1040 .context("Failed to open git repository. Make sure you're in a git repository.")?;
1041
1042 let current_branch = repo.get_current_branch().context(
1044 "Failed to get current branch. Make sure you're not in detached HEAD state.",
1045 )?;
1046
1047 let base_branch = match self.base_branch {
1049 Some(branch) => {
1050 if !repo.branch_exists(&branch)? {
1052 anyhow::bail!("Base branch '{}' does not exist", branch);
1053 }
1054 branch
1055 }
1056 None => {
1057 if repo.branch_exists("main")? {
1059 "main".to_string()
1060 } else if repo.branch_exists("master")? {
1061 "master".to_string()
1062 } else {
1063 anyhow::bail!("No default base branch found (main or master)");
1064 }
1065 }
1066 };
1067
1068 let commit_range = format!("{}..HEAD", base_branch);
1070
1071 let wd_status = repo.get_working_directory_status()?;
1073 let working_directory = WorkingDirectoryInfo {
1074 clean: wd_status.clean,
1075 untracked_changes: wd_status
1076 .untracked_changes
1077 .into_iter()
1078 .map(|fs| FileStatusInfo {
1079 status: fs.status,
1080 file: fs.file,
1081 })
1082 .collect(),
1083 };
1084
1085 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1087
1088 let commits = repo.get_commits_in_range(&commit_range)?;
1090
1091 let pr_template_result = Self::read_pr_template().ok();
1093 let (pr_template, pr_template_location) = match pr_template_result {
1094 Some((content, location)) => (Some(content), Some(location)),
1095 None => (None, None),
1096 };
1097
1098 let branch_prs = Self::get_branch_prs(¤t_branch)
1100 .ok()
1101 .filter(|prs| !prs.is_empty());
1102
1103 let versions = Some(VersionInfo {
1105 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1106 });
1107
1108 let ai_scratch_path =
1110 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
1111 let ai_info = AiInfo {
1112 scratch: ai_scratch_path.to_string_lossy().to_string(),
1113 };
1114
1115 let mut repo_view = RepositoryView {
1117 versions,
1118 explanation: FieldExplanation::default(),
1119 working_directory,
1120 remotes,
1121 ai: ai_info,
1122 branch_info: Some(BranchInfo {
1123 branch: current_branch,
1124 }),
1125 pr_template,
1126 pr_template_location,
1127 branch_prs,
1128 commits,
1129 };
1130
1131 repo_view.update_field_presence();
1133
1134 let yaml_output = crate::data::to_yaml(&repo_view)?;
1136 println!("{}", yaml_output);
1137
1138 Ok(())
1139 }
1140
1141 fn read_pr_template() -> Result<(String, String)> {
1143 use std::fs;
1144 use std::path::Path;
1145
1146 let template_path = Path::new(".github/pull_request_template.md");
1147 if template_path.exists() {
1148 let content = fs::read_to_string(template_path)
1149 .context("Failed to read .github/pull_request_template.md")?;
1150 Ok((content, template_path.to_string_lossy().to_string()))
1151 } else {
1152 anyhow::bail!("PR template file does not exist")
1153 }
1154 }
1155
1156 fn get_branch_prs(branch_name: &str) -> Result<Vec<crate::data::PullRequest>> {
1158 use serde_json::Value;
1159 use std::process::Command;
1160
1161 let output = Command::new("gh")
1163 .args([
1164 "pr",
1165 "list",
1166 "--head",
1167 branch_name,
1168 "--json",
1169 "number,title,state,url,body",
1170 "--limit",
1171 "50",
1172 ])
1173 .output()
1174 .context("Failed to execute gh command")?;
1175
1176 if !output.status.success() {
1177 anyhow::bail!(
1178 "gh command failed: {}",
1179 String::from_utf8_lossy(&output.stderr)
1180 );
1181 }
1182
1183 let json_str = String::from_utf8_lossy(&output.stdout);
1184 let prs_json: Value =
1185 serde_json::from_str(&json_str).context("Failed to parse PR JSON from gh")?;
1186
1187 let mut prs = Vec::new();
1188 if let Some(prs_array) = prs_json.as_array() {
1189 for pr_json in prs_array {
1190 if let (Some(number), Some(title), Some(state), Some(url), Some(body)) = (
1191 pr_json.get("number").and_then(|n| n.as_u64()),
1192 pr_json.get("title").and_then(|t| t.as_str()),
1193 pr_json.get("state").and_then(|s| s.as_str()),
1194 pr_json.get("url").and_then(|u| u.as_str()),
1195 pr_json.get("body").and_then(|b| b.as_str()),
1196 ) {
1197 prs.push(crate::data::PullRequest {
1198 number,
1199 title: title.to_string(),
1200 state: state.to_string(),
1201 url: url.to_string(),
1202 body: body.to_string(),
1203 });
1204 }
1205 }
1206 }
1207
1208 Ok(prs)
1209 }
1210}
1211
1212#[derive(Debug, PartialEq)]
1214enum PrAction {
1215 CreateNew,
1216 UpdateExisting,
1217 Cancel,
1218}
1219
1220#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
1222pub struct PrContent {
1223 pub title: String,
1225 pub description: String,
1227}
1228
1229impl CreateCommand {
1230 pub async fn execute(self) -> Result<()> {
1232 match self.command {
1233 CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute().await,
1234 }
1235 }
1236}
1237
1238impl CreatePrCommand {
1239 fn should_create_as_draft(&self) -> bool {
1247 use crate::utils::settings::get_env_var;
1248
1249 if self.ready {
1251 return false;
1252 }
1253 if self.draft {
1254 return true;
1255 }
1256
1257 get_env_var("OMNI_DEV_DEFAULT_DRAFT_PR")
1259 .ok()
1260 .and_then(|val| match val.to_lowercase().as_str() {
1261 "true" | "1" | "yes" => Some(true),
1262 "false" | "0" | "no" => Some(false),
1263 _ => None,
1264 })
1265 .unwrap_or(true) }
1267
1268 pub async fn execute(self) -> Result<()> {
1270 println!("๐ Starting pull request creation process...");
1271
1272 let repo_view = self.generate_repository_view()?;
1274
1275 self.validate_branch_state(&repo_view)?;
1277
1278 use crate::claude::context::ProjectDiscovery;
1280 let repo_root = std::path::PathBuf::from(".");
1281 let context_dir = std::path::PathBuf::from(".omni-dev");
1282 let discovery = ProjectDiscovery::new(repo_root, context_dir);
1283 let project_context = discovery.discover().unwrap_or_default();
1284 self.show_guidance_files_status(&project_context)?;
1285
1286 let claude_client = crate::claude::create_default_claude_client(None)?;
1288 self.show_model_info_from_client(&claude_client)?;
1289
1290 self.show_commit_range_info(&repo_view)?;
1292
1293 let context = {
1295 use crate::claude::context::{BranchAnalyzer, WorkPatternAnalyzer};
1296 use crate::data::context::CommitContext;
1297 let mut context = CommitContext::new();
1298 context.project = project_context;
1299
1300 if let Some(branch_info) = &repo_view.branch_info {
1302 context.branch = BranchAnalyzer::analyze(&branch_info.branch).unwrap_or_default();
1303 }
1304
1305 if !repo_view.commits.is_empty() {
1306 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
1307 }
1308 context
1309 };
1310 self.show_context_summary(&context)?;
1311
1312 debug!("About to generate PR content from AI");
1314 let (pr_content, _claude_client) = self
1315 .generate_pr_content_with_client_internal(&repo_view, claude_client)
1316 .await?;
1317
1318 self.show_context_information(&repo_view).await?;
1320 debug!(
1321 generated_title = %pr_content.title,
1322 generated_description_length = pr_content.description.len(),
1323 generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1324 "Generated PR content from AI"
1325 );
1326
1327 if let Some(save_path) = self.save_only {
1329 let pr_yaml = crate::data::to_yaml(&pr_content)
1330 .context("Failed to serialize PR content to YAML")?;
1331 std::fs::write(&save_path, &pr_yaml).context("Failed to save PR details to file")?;
1332 println!("๐พ PR details saved to: {}", save_path);
1333 return Ok(());
1334 }
1335
1336 debug!("About to serialize PR content to YAML");
1338 let temp_dir = tempfile::tempdir()?;
1339 let pr_file = temp_dir.path().join("pr-details.yaml");
1340
1341 debug!(
1342 pre_serialize_title = %pr_content.title,
1343 pre_serialize_description_length = pr_content.description.len(),
1344 pre_serialize_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1345 "About to serialize PR content with to_yaml"
1346 );
1347
1348 let pr_yaml =
1349 crate::data::to_yaml(&pr_content).context("Failed to serialize PR content to YAML")?;
1350
1351 debug!(
1352 file_path = %pr_file.display(),
1353 yaml_content_length = pr_yaml.len(),
1354 yaml_content = %pr_yaml,
1355 original_title = %pr_content.title,
1356 original_description_length = pr_content.description.len(),
1357 "Writing PR details to temporary YAML file"
1358 );
1359
1360 std::fs::write(&pr_file, &pr_yaml)?;
1361
1362 let pr_action = if self.auto_apply {
1364 if repo_view
1366 .branch_prs
1367 .as_ref()
1368 .is_some_and(|prs| !prs.is_empty())
1369 {
1370 PrAction::UpdateExisting
1371 } else {
1372 PrAction::CreateNew
1373 }
1374 } else {
1375 self.handle_pr_file(&pr_file, &repo_view)?
1376 };
1377
1378 if pr_action == PrAction::Cancel {
1379 println!("โ PR operation cancelled by user");
1380 return Ok(());
1381 }
1382
1383 self.validate_environment()?;
1385
1386 let final_pr_yaml =
1388 std::fs::read_to_string(&pr_file).context("Failed to read PR details file")?;
1389
1390 debug!(
1391 yaml_length = final_pr_yaml.len(),
1392 yaml_content = %final_pr_yaml,
1393 "Read PR details YAML from file"
1394 );
1395
1396 let final_pr_content: PrContent = serde_yaml::from_str(&final_pr_yaml)
1397 .context("Failed to parse PR details YAML. Please check the file format.")?;
1398
1399 debug!(
1400 title = %final_pr_content.title,
1401 description_length = final_pr_content.description.len(),
1402 description_preview = %final_pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1403 "Parsed PR content from YAML"
1404 );
1405
1406 let is_draft = self.should_create_as_draft();
1408
1409 match pr_action {
1410 PrAction::CreateNew => {
1411 self.create_github_pr(
1412 &repo_view,
1413 &final_pr_content.title,
1414 &final_pr_content.description,
1415 is_draft,
1416 )?;
1417 println!("โ
Pull request created successfully!");
1418 }
1419 PrAction::UpdateExisting => {
1420 self.update_github_pr(
1421 &repo_view,
1422 &final_pr_content.title,
1423 &final_pr_content.description,
1424 )?;
1425 println!("โ
Pull request updated successfully!");
1426 }
1427 PrAction::Cancel => unreachable!(), }
1429
1430 Ok(())
1431 }
1432
1433 fn validate_environment(&self) -> Result<()> {
1435 let gh_check = std::process::Command::new("gh")
1437 .args(["--version"])
1438 .output();
1439
1440 match gh_check {
1441 Ok(output) if output.status.success() => {
1442 let repo_check = std::process::Command::new("gh")
1444 .args(["repo", "view", "--json", "name"])
1445 .output();
1446
1447 match repo_check {
1448 Ok(repo_output) if repo_output.status.success() => Ok(()),
1449 Ok(repo_output) => {
1450 let error_details = String::from_utf8_lossy(&repo_output.stderr);
1452 if error_details.contains("authentication") || error_details.contains("login") {
1453 anyhow::bail!("GitHub CLI (gh) authentication failed. Please run 'gh auth login' or check your GITHUB_TOKEN environment variable.")
1454 } else {
1455 anyhow::bail!("GitHub CLI (gh) cannot access this repository. Error: {}", error_details.trim())
1456 }
1457 }
1458 Err(e) => anyhow::bail!("Failed to test GitHub CLI access: {}", e),
1459 }
1460 }
1461 _ => anyhow::bail!("GitHub CLI (gh) is not installed or not available in PATH. Please install it from https://cli.github.com/"),
1462 }
1463 }
1464
1465 fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
1467 use crate::data::{
1468 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
1469 WorkingDirectoryInfo,
1470 };
1471 use crate::git::{GitRepository, RemoteInfo};
1472 use crate::utils::ai_scratch;
1473
1474 let repo = GitRepository::open()
1476 .context("Failed to open git repository. Make sure you're in a git repository.")?;
1477
1478 let current_branch = repo.get_current_branch().context(
1480 "Failed to get current branch. Make sure you're not in detached HEAD state.",
1481 )?;
1482
1483 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1485
1486 let primary_remote = remotes
1488 .iter()
1489 .find(|r| r.name == "origin")
1490 .or_else(|| remotes.first())
1491 .ok_or_else(|| anyhow::anyhow!("No remotes found in repository"))?;
1492
1493 let base_branch = match self.base_branch.as_ref() {
1495 Some(branch) => {
1496 let remote_branch = if branch.contains('/') {
1498 branch.clone()
1500 } else {
1501 format!("{}/{}", primary_remote.name, branch)
1503 };
1504
1505 let remote_ref = format!("refs/remotes/{}", remote_branch);
1507 if repo.repository().find_reference(&remote_ref).is_err() {
1508 anyhow::bail!("Remote branch '{}' does not exist", remote_branch);
1509 }
1510 remote_branch
1511 }
1512 None => {
1513 let main_branch = &primary_remote.main_branch;
1515 if main_branch == "unknown" {
1516 anyhow::bail!(
1517 "Could not determine main branch for remote '{}'",
1518 primary_remote.name
1519 );
1520 }
1521
1522 let remote_main = format!("{}/{}", primary_remote.name, main_branch);
1523
1524 let remote_ref = format!("refs/remotes/{}", remote_main);
1526 if repo.repository().find_reference(&remote_ref).is_err() {
1527 anyhow::bail!(
1528 "Remote main branch '{}' does not exist. Try running 'git fetch' first.",
1529 remote_main
1530 );
1531 }
1532
1533 remote_main
1534 }
1535 };
1536
1537 let commit_range = format!("{}..HEAD", base_branch);
1539
1540 let wd_status = repo.get_working_directory_status()?;
1542 let working_directory = WorkingDirectoryInfo {
1543 clean: wd_status.clean,
1544 untracked_changes: wd_status
1545 .untracked_changes
1546 .into_iter()
1547 .map(|fs| FileStatusInfo {
1548 status: fs.status,
1549 file: fs.file,
1550 })
1551 .collect(),
1552 };
1553
1554 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
1556
1557 let commits = repo.get_commits_in_range(&commit_range)?;
1559
1560 let pr_template_result = InfoCommand::read_pr_template().ok();
1562 let (pr_template, pr_template_location) = match pr_template_result {
1563 Some((content, location)) => (Some(content), Some(location)),
1564 None => (None, None),
1565 };
1566
1567 let branch_prs = InfoCommand::get_branch_prs(¤t_branch)
1569 .ok()
1570 .filter(|prs| !prs.is_empty());
1571
1572 let versions = Some(VersionInfo {
1574 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
1575 });
1576
1577 let ai_scratch_path =
1579 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
1580 let ai_info = AiInfo {
1581 scratch: ai_scratch_path.to_string_lossy().to_string(),
1582 };
1583
1584 let mut repo_view = RepositoryView {
1586 versions,
1587 explanation: FieldExplanation::default(),
1588 working_directory,
1589 remotes,
1590 ai: ai_info,
1591 branch_info: Some(BranchInfo {
1592 branch: current_branch,
1593 }),
1594 pr_template,
1595 pr_template_location,
1596 branch_prs,
1597 commits,
1598 };
1599
1600 repo_view.update_field_presence();
1602
1603 Ok(repo_view)
1604 }
1605
1606 fn validate_branch_state(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
1608 if !repo_view.working_directory.clean {
1610 anyhow::bail!(
1611 "Working directory has uncommitted changes. Please commit or stash your changes before creating a PR."
1612 );
1613 }
1614
1615 if !repo_view.working_directory.untracked_changes.is_empty() {
1617 let file_list: Vec<&str> = repo_view
1618 .working_directory
1619 .untracked_changes
1620 .iter()
1621 .map(|f| f.file.as_str())
1622 .collect();
1623 anyhow::bail!(
1624 "Working directory has untracked changes: {}. Please commit or stash your changes before creating a PR.",
1625 file_list.join(", ")
1626 );
1627 }
1628
1629 if repo_view.commits.is_empty() {
1631 anyhow::bail!("No commits found to create PR from. Make sure you have commits that are not in the base branch.");
1632 }
1633
1634 if let Some(existing_prs) = &repo_view.branch_prs {
1636 if !existing_prs.is_empty() {
1637 let pr_info: Vec<String> = existing_prs
1638 .iter()
1639 .map(|pr| format!("#{} ({})", pr.number, pr.state))
1640 .collect();
1641
1642 println!(
1643 "๐ Existing PR(s) found for this branch: {}",
1644 pr_info.join(", ")
1645 );
1646 }
1648 }
1649
1650 Ok(())
1651 }
1652
1653 async fn show_context_information(
1655 &self,
1656 _repo_view: &crate::data::RepositoryView,
1657 ) -> Result<()> {
1658 Ok(())
1663 }
1664
1665 fn show_commit_range_info(&self, repo_view: &crate::data::RepositoryView) -> Result<()> {
1667 let base_branch = match self.base_branch.as_ref() {
1669 Some(branch) => {
1670 if branch.contains('/') {
1672 branch.clone()
1674 } else {
1675 let primary_remote_name = repo_view
1677 .remotes
1678 .iter()
1679 .find(|r| r.name == "origin")
1680 .or_else(|| repo_view.remotes.first())
1681 .map(|r| r.name.as_str())
1682 .unwrap_or("origin");
1683 format!("{}/{}", primary_remote_name, branch)
1684 }
1685 }
1686 None => {
1687 repo_view
1689 .remotes
1690 .iter()
1691 .find(|r| r.name == "origin")
1692 .or_else(|| repo_view.remotes.first())
1693 .map(|r| format!("{}/{}", r.name, r.main_branch))
1694 .unwrap_or_else(|| "unknown".to_string())
1695 }
1696 };
1697
1698 let commit_range = format!("{}..HEAD", base_branch);
1699 let commit_count = repo_view.commits.len();
1700
1701 let current_branch = repo_view
1703 .branch_info
1704 .as_ref()
1705 .map(|bi| bi.branch.as_str())
1706 .unwrap_or("unknown");
1707
1708 println!("๐ Branch Analysis:");
1709 println!(" ๐ฟ Current branch: {}", current_branch);
1710 println!(" ๐ Commit range: {}", commit_range);
1711 println!(" ๐ Commits found: {} commits", commit_count);
1712 println!();
1713
1714 Ok(())
1715 }
1716
1717 async fn collect_context(
1719 &self,
1720 repo_view: &crate::data::RepositoryView,
1721 ) -> Result<crate::data::context::CommitContext> {
1722 use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
1723 use crate::data::context::CommitContext;
1724 use crate::git::GitRepository;
1725
1726 let mut context = CommitContext::new();
1727
1728 let context_dir = std::path::PathBuf::from(".omni-dev");
1730
1731 let repo_root = std::path::PathBuf::from(".");
1733 let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
1734 match discovery.discover() {
1735 Ok(project_context) => {
1736 context.project = project_context;
1737 }
1738 Err(_e) => {
1739 context.project = Default::default();
1740 }
1741 }
1742
1743 let repo = GitRepository::open()?;
1745 let current_branch = repo
1746 .get_current_branch()
1747 .unwrap_or_else(|_| "HEAD".to_string());
1748 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
1749
1750 if !repo_view.commits.is_empty() {
1752 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
1753 }
1754
1755 Ok(context)
1756 }
1757
1758 fn show_guidance_files_status(
1760 &self,
1761 project_context: &crate::data::context::ProjectContext,
1762 ) -> Result<()> {
1763 let context_dir = std::path::PathBuf::from(".omni-dev");
1764
1765 println!("๐ Project guidance files status:");
1766
1767 let pr_guidelines_found = project_context.pr_guidelines.is_some();
1769 let pr_guidelines_source = if pr_guidelines_found {
1770 let local_path = context_dir.join("local").join("pr-guidelines.md");
1771 let project_path = context_dir.join("pr-guidelines.md");
1772 let home_path = dirs::home_dir()
1773 .map(|h| h.join(".omni-dev").join("pr-guidelines.md"))
1774 .unwrap_or_default();
1775
1776 if local_path.exists() {
1777 format!("โ
Local override: {}", local_path.display())
1778 } else if project_path.exists() {
1779 format!("โ
Project: {}", project_path.display())
1780 } else if home_path.exists() {
1781 format!("โ
Global: {}", home_path.display())
1782 } else {
1783 "โ
(source unknown)".to_string()
1784 }
1785 } else {
1786 "โ None found".to_string()
1787 };
1788 println!(" ๐ PR guidelines: {}", pr_guidelines_source);
1789
1790 let scopes_count = project_context.valid_scopes.len();
1792 let scopes_source = if scopes_count > 0 {
1793 let local_path = context_dir.join("local").join("scopes.yaml");
1794 let project_path = context_dir.join("scopes.yaml");
1795 let home_path = dirs::home_dir()
1796 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
1797 .unwrap_or_default();
1798
1799 let source = if local_path.exists() {
1800 format!("Local override: {}", local_path.display())
1801 } else if project_path.exists() {
1802 format!("Project: {}", project_path.display())
1803 } else if home_path.exists() {
1804 format!("Global: {}", home_path.display())
1805 } else {
1806 "(source unknown + ecosystem defaults)".to_string()
1807 };
1808 format!("โ
{} ({} scopes)", source, scopes_count)
1809 } else {
1810 "โ None found".to_string()
1811 };
1812 println!(" ๐ฏ Valid scopes: {}", scopes_source);
1813
1814 let pr_template_path = std::path::Path::new(".github/pull_request_template.md");
1816 let pr_template_status = if pr_template_path.exists() {
1817 format!("โ
Project: {}", pr_template_path.display())
1818 } else {
1819 "โ None found".to_string()
1820 };
1821 println!(" ๐ PR template: {}", pr_template_status);
1822
1823 println!();
1824 Ok(())
1825 }
1826
1827 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
1829 use crate::data::context::{VerbosityLevel, WorkPattern};
1830
1831 println!("๐ Context Analysis:");
1832
1833 if !context.project.valid_scopes.is_empty() {
1835 let scope_names: Vec<&str> = context
1836 .project
1837 .valid_scopes
1838 .iter()
1839 .map(|s| s.name.as_str())
1840 .collect();
1841 println!(" ๐ Valid scopes: {}", scope_names.join(", "));
1842 }
1843
1844 if context.branch.is_feature_branch {
1846 println!(
1847 " ๐ฟ Branch: {} ({})",
1848 context.branch.description, context.branch.work_type
1849 );
1850 if let Some(ref ticket) = context.branch.ticket_id {
1851 println!(" ๐ซ Ticket: {}", ticket);
1852 }
1853 }
1854
1855 match context.range.work_pattern {
1857 WorkPattern::Sequential => println!(" ๐ Pattern: Sequential development"),
1858 WorkPattern::Refactoring => println!(" ๐งน Pattern: Refactoring work"),
1859 WorkPattern::BugHunt => println!(" ๐ Pattern: Bug investigation"),
1860 WorkPattern::Documentation => println!(" ๐ Pattern: Documentation updates"),
1861 WorkPattern::Configuration => println!(" โ๏ธ Pattern: Configuration changes"),
1862 WorkPattern::Unknown => {}
1863 }
1864
1865 match context.suggested_verbosity() {
1867 VerbosityLevel::Comprehensive => {
1868 println!(" ๐ Detail level: Comprehensive (significant changes detected)")
1869 }
1870 VerbosityLevel::Detailed => println!(" ๐ Detail level: Detailed"),
1871 VerbosityLevel::Concise => println!(" ๐ Detail level: Concise"),
1872 }
1873
1874 println!();
1875 Ok(())
1876 }
1877
1878 async fn generate_pr_content_with_client_internal(
1880 &self,
1881 repo_view: &crate::data::RepositoryView,
1882 claude_client: crate::claude::client::ClaudeClient,
1883 ) -> Result<(PrContent, crate::claude::client::ClaudeClient)> {
1884 use tracing::debug;
1885
1886 let pr_template = match &repo_view.pr_template {
1888 Some(template) => template.clone(),
1889 None => self.get_default_pr_template(),
1890 };
1891
1892 debug!(
1893 pr_template_length = pr_template.len(),
1894 pr_template_preview = %pr_template.lines().take(5).collect::<Vec<_>>().join("\\n"),
1895 "Using PR template for generation"
1896 );
1897
1898 println!("๐ค Generating AI-powered PR description...");
1899
1900 debug!("Collecting context for PR generation");
1902 let context = self.collect_context(repo_view).await?;
1903 debug!("Context collection completed");
1904
1905 debug!("About to call Claude AI for PR content generation");
1907 match claude_client
1908 .generate_pr_content_with_context(repo_view, &pr_template, &context)
1909 .await
1910 {
1911 Ok(pr_content) => {
1912 debug!(
1913 ai_generated_title = %pr_content.title,
1914 ai_generated_description_length = pr_content.description.len(),
1915 ai_generated_description_preview = %pr_content.description.lines().take(3).collect::<Vec<_>>().join("\\n"),
1916 "AI successfully generated PR content"
1917 );
1918 Ok((pr_content, claude_client))
1919 }
1920 Err(e) => {
1921 debug!(error = %e, "AI PR generation failed, falling back to basic description");
1922 let mut description = pr_template;
1924 self.enhance_description_with_commits(&mut description, repo_view)?;
1925
1926 let title = self.generate_title_from_commits(repo_view);
1928
1929 debug!(
1930 fallback_title = %title,
1931 fallback_description_length = description.len(),
1932 "Created fallback PR content"
1933 );
1934
1935 Ok((PrContent { title, description }, claude_client))
1936 }
1937 }
1938 }
1939
1940 fn get_default_pr_template(&self) -> String {
1942 r#"# Pull Request
1943
1944## Description
1945<!-- Provide a brief description of what this PR does -->
1946
1947## Type of Change
1948<!-- Mark the relevant option with an "x" -->
1949- [ ] Bug fix (non-breaking change which fixes an issue)
1950- [ ] New feature (non-breaking change which adds functionality)
1951- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
1952- [ ] Documentation update
1953- [ ] Refactoring (no functional changes)
1954- [ ] Performance improvement
1955- [ ] Test coverage improvement
1956
1957## Changes Made
1958<!-- List the specific changes made in this PR -->
1959-
1960-
1961-
1962
1963## Testing
1964- [ ] All existing tests pass
1965- [ ] New tests added for new functionality
1966- [ ] Manual testing performed
1967
1968## Additional Notes
1969<!-- Add any additional notes for reviewers -->
1970"#.to_string()
1971 }
1972
1973 fn enhance_description_with_commits(
1975 &self,
1976 description: &mut String,
1977 repo_view: &crate::data::RepositoryView,
1978 ) -> Result<()> {
1979 if repo_view.commits.is_empty() {
1980 return Ok(());
1981 }
1982
1983 description.push_str("\n---\n");
1985 description.push_str("## ๐ Commit Summary\n");
1986 description
1987 .push_str("*This section was automatically generated based on commit analysis*\n\n");
1988
1989 let mut types_found = std::collections::HashSet::new();
1991 let mut scopes_found = std::collections::HashSet::new();
1992 let mut has_breaking_changes = false;
1993
1994 for commit in &repo_view.commits {
1995 let detected_type = &commit.analysis.detected_type;
1996 types_found.insert(detected_type.clone());
1997 if detected_type.contains("BREAKING")
1998 || commit.original_message.contains("BREAKING CHANGE")
1999 {
2000 has_breaking_changes = true;
2001 }
2002
2003 let detected_scope = &commit.analysis.detected_scope;
2004 if !detected_scope.is_empty() {
2005 scopes_found.insert(detected_scope.clone());
2006 }
2007 }
2008
2009 if let Some(feat_pos) = description.find("- [ ] New feature") {
2011 if types_found.contains("feat") {
2012 description.replace_range(feat_pos..feat_pos + 5, "- [x]");
2013 }
2014 }
2015 if let Some(fix_pos) = description.find("- [ ] Bug fix") {
2016 if types_found.contains("fix") {
2017 description.replace_range(fix_pos..fix_pos + 5, "- [x]");
2018 }
2019 }
2020 if let Some(docs_pos) = description.find("- [ ] Documentation update") {
2021 if types_found.contains("docs") {
2022 description.replace_range(docs_pos..docs_pos + 5, "- [x]");
2023 }
2024 }
2025 if let Some(refactor_pos) = description.find("- [ ] Refactoring") {
2026 if types_found.contains("refactor") {
2027 description.replace_range(refactor_pos..refactor_pos + 5, "- [x]");
2028 }
2029 }
2030 if let Some(breaking_pos) = description.find("- [ ] Breaking change") {
2031 if has_breaking_changes {
2032 description.replace_range(breaking_pos..breaking_pos + 5, "- [x]");
2033 }
2034 }
2035
2036 if !scopes_found.is_empty() {
2038 let scopes_list: Vec<_> = scopes_found.into_iter().collect();
2039 description.push_str(&format!(
2040 "**Affected areas:** {}\n\n",
2041 scopes_list.join(", ")
2042 ));
2043 }
2044
2045 description.push_str("### Commits in this PR:\n");
2047 for commit in &repo_view.commits {
2048 let short_hash = &commit.hash[..8];
2049 let first_line = commit.original_message.lines().next().unwrap_or("").trim();
2050 description.push_str(&format!("- `{}` {}\n", short_hash, first_line));
2051 }
2052
2053 let total_files: usize = repo_view
2055 .commits
2056 .iter()
2057 .map(|c| c.analysis.file_changes.total_files)
2058 .sum();
2059
2060 if total_files > 0 {
2061 description.push_str(&format!("\n**Files changed:** {} files\n", total_files));
2062 }
2063
2064 Ok(())
2065 }
2066
2067 fn handle_pr_file(
2069 &self,
2070 pr_file: &std::path::Path,
2071 repo_view: &crate::data::RepositoryView,
2072 ) -> Result<PrAction> {
2073 use std::io::{self, Write};
2074
2075 println!("\n๐ PR details generated.");
2076 println!("๐พ Details saved to: {}", pr_file.display());
2077
2078 let is_draft = self.should_create_as_draft();
2080 let status_icon = if is_draft { "๐" } else { "โ
" };
2081 let status_text = if is_draft {
2082 "draft"
2083 } else {
2084 "ready for review"
2085 };
2086 println!("{} PR will be created as: {}", status_icon, status_text);
2087 println!();
2088
2089 let has_existing_prs = repo_view
2091 .branch_prs
2092 .as_ref()
2093 .is_some_and(|prs| !prs.is_empty());
2094
2095 loop {
2096 if has_existing_prs {
2097 print!("โ [U]pdate existing PR, [N]ew PR anyway, [S]how file, [E]dit file, or [Q]uit? [U/n/s/e/q] ");
2098 } else {
2099 print!(
2100 "โ [A]ccept and create PR, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] "
2101 );
2102 }
2103 io::stdout().flush()?;
2104
2105 let mut input = String::new();
2106 io::stdin().read_line(&mut input)?;
2107
2108 match input.trim().to_lowercase().as_str() {
2109 "u" | "update" if has_existing_prs => return Ok(PrAction::UpdateExisting),
2110 "n" | "new" if has_existing_prs => return Ok(PrAction::CreateNew),
2111 "a" | "accept" | "" if !has_existing_prs => return Ok(PrAction::CreateNew),
2112 "s" | "show" => {
2113 self.show_pr_file(pr_file)?;
2114 println!();
2115 }
2116 "e" | "edit" => {
2117 self.edit_pr_file(pr_file)?;
2118 println!();
2119 }
2120 "q" | "quit" => return Ok(PrAction::Cancel),
2121 _ => {
2122 if has_existing_prs {
2123 println!("Invalid choice. Please enter 'u' to update existing PR, 'n' for new PR, 's' to show, 'e' to edit, or 'q' to quit.");
2124 } else {
2125 println!("Invalid choice. Please enter 'a' to accept, 's' to show, 'e' to edit, or 'q' to quit.");
2126 }
2127 }
2128 }
2129 }
2130 }
2131
2132 fn show_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
2134 use std::fs;
2135
2136 println!("\n๐ PR details file contents:");
2137 println!("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ");
2138
2139 let contents = fs::read_to_string(pr_file).context("Failed to read PR details file")?;
2140 println!("{}", contents);
2141 println!("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ");
2142
2143 Ok(())
2144 }
2145
2146 fn edit_pr_file(&self, pr_file: &std::path::Path) -> Result<()> {
2148 use std::env;
2149 use std::io::{self, Write};
2150 use std::process::Command;
2151
2152 let editor = env::var("OMNI_DEV_EDITOR")
2154 .or_else(|_| env::var("EDITOR"))
2155 .unwrap_or_else(|_| {
2156 println!(
2158 "๐ง Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
2159 );
2160 print!("Please enter the command to use as your editor: ");
2161 io::stdout().flush().expect("Failed to flush stdout");
2162
2163 let mut input = String::new();
2164 io::stdin()
2165 .read_line(&mut input)
2166 .expect("Failed to read user input");
2167 input.trim().to_string()
2168 });
2169
2170 if editor.is_empty() {
2171 println!("โ No editor specified. Returning to menu.");
2172 return Ok(());
2173 }
2174
2175 println!("๐ Opening PR details file in editor: {}", editor);
2176
2177 let mut cmd_parts = editor.split_whitespace();
2179 let editor_cmd = cmd_parts.next().unwrap_or(&editor);
2180 let args: Vec<&str> = cmd_parts.collect();
2181
2182 let mut command = Command::new(editor_cmd);
2183 command.args(args);
2184 command.arg(pr_file.to_string_lossy().as_ref());
2185
2186 match command.status() {
2187 Ok(status) => {
2188 if status.success() {
2189 println!("โ
Editor session completed.");
2190 } else {
2191 println!(
2192 "โ ๏ธ Editor exited with non-zero status: {:?}",
2193 status.code()
2194 );
2195 }
2196 }
2197 Err(e) => {
2198 println!("โ Failed to execute editor '{}': {}", editor, e);
2199 println!(" Please check that the editor command is correct and available in your PATH.");
2200 }
2201 }
2202
2203 Ok(())
2204 }
2205
2206 fn generate_title_from_commits(&self, repo_view: &crate::data::RepositoryView) -> String {
2208 if repo_view.commits.is_empty() {
2209 return "Pull Request".to_string();
2210 }
2211
2212 if repo_view.commits.len() == 1 {
2214 return repo_view.commits[0]
2215 .original_message
2216 .lines()
2217 .next()
2218 .unwrap_or("Pull Request")
2219 .trim()
2220 .to_string();
2221 }
2222
2223 let branch_name = repo_view
2225 .branch_info
2226 .as_ref()
2227 .map(|bi| bi.branch.as_str())
2228 .unwrap_or("feature");
2229
2230 let cleaned_branch = branch_name.replace(['/', '-', '_'], " ");
2231
2232 format!("feat: {}", cleaned_branch)
2233 }
2234
2235 fn create_github_pr(
2237 &self,
2238 repo_view: &crate::data::RepositoryView,
2239 title: &str,
2240 description: &str,
2241 is_draft: bool,
2242 ) -> Result<()> {
2243 use std::process::Command;
2244
2245 let branch_name = repo_view
2247 .branch_info
2248 .as_ref()
2249 .map(|bi| &bi.branch)
2250 .context("Branch info not available")?;
2251
2252 let pr_status = if is_draft {
2253 "draft"
2254 } else {
2255 "ready for review"
2256 };
2257 println!("๐ Creating pull request ({})...", pr_status);
2258 println!(" ๐ Title: {}", title);
2259 println!(" ๐ฟ Branch: {}", branch_name);
2260
2261 debug!("Opening git repository to check branch status");
2263 let git_repo =
2264 crate::git::GitRepository::open().context("Failed to open git repository")?;
2265
2266 debug!(
2267 "Checking if branch '{}' exists on remote 'origin'",
2268 branch_name
2269 );
2270 if !git_repo.branch_exists_on_remote(branch_name, "origin")? {
2271 println!("๐ค Pushing branch to remote...");
2272 debug!(
2273 "Branch '{}' not found on remote, attempting to push",
2274 branch_name
2275 );
2276 git_repo
2277 .push_branch(branch_name, "origin")
2278 .context("Failed to push branch to remote")?;
2279 } else {
2280 debug!("Branch '{}' already exists on remote 'origin'", branch_name);
2281 }
2282
2283 debug!("Creating PR with gh CLI - title: '{}'", title);
2285 debug!("PR description length: {} characters", description.len());
2286 debug!("PR draft status: {}", is_draft);
2287
2288 let mut args = vec![
2289 "pr",
2290 "create",
2291 "--head",
2292 branch_name,
2293 "--title",
2294 title,
2295 "--body",
2296 description,
2297 ];
2298
2299 if is_draft {
2300 args.push("--draft");
2301 }
2302
2303 let pr_result = Command::new("gh")
2304 .args(&args)
2305 .output()
2306 .context("Failed to create pull request")?;
2307
2308 if pr_result.status.success() {
2309 let pr_url = String::from_utf8_lossy(&pr_result.stdout);
2310 let pr_url = pr_url.trim();
2311 debug!("PR created successfully with URL: {}", pr_url);
2312 println!("๐ Pull request created: {}", pr_url);
2313 } else {
2314 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
2315 error!("gh CLI failed to create PR: {}", error_msg);
2316 anyhow::bail!("Failed to create pull request: {}", error_msg);
2317 }
2318
2319 Ok(())
2320 }
2321
2322 fn update_github_pr(
2324 &self,
2325 repo_view: &crate::data::RepositoryView,
2326 title: &str,
2327 description: &str,
2328 ) -> Result<()> {
2329 use std::process::Command;
2330
2331 let pr_number = repo_view
2333 .branch_prs
2334 .as_ref()
2335 .and_then(|prs| prs.first())
2336 .map(|pr| pr.number)
2337 .context("No existing PR found to update")?;
2338
2339 println!("๐ Updating pull request #{}...", pr_number);
2340 println!(" ๐ Title: {}", title);
2341
2342 debug!(
2343 pr_number = pr_number,
2344 title = %title,
2345 description_length = description.len(),
2346 description_preview = %description.lines().take(3).collect::<Vec<_>>().join("\\n"),
2347 "Updating GitHub PR with title and description"
2348 );
2349
2350 let gh_args = [
2352 "pr",
2353 "edit",
2354 &pr_number.to_string(),
2355 "--title",
2356 title,
2357 "--body",
2358 description,
2359 ];
2360
2361 debug!(
2362 args = ?gh_args,
2363 "Executing gh command to update PR"
2364 );
2365
2366 let pr_result = Command::new("gh")
2367 .args(gh_args)
2368 .output()
2369 .context("Failed to update pull request")?;
2370
2371 if pr_result.status.success() {
2372 if let Some(existing_pr) = repo_view.branch_prs.as_ref().and_then(|prs| prs.first()) {
2374 println!("๐ Pull request updated: {}", existing_pr.url);
2375 } else {
2376 println!("๐ Pull request #{} updated successfully!", pr_number);
2377 }
2378 } else {
2379 let error_msg = String::from_utf8_lossy(&pr_result.stderr);
2380 anyhow::bail!("Failed to update pull request: {}", error_msg);
2381 }
2382
2383 Ok(())
2384 }
2385
2386 fn show_model_info_from_client(
2388 &self,
2389 client: &crate::claude::client::ClaudeClient,
2390 ) -> Result<()> {
2391 use crate::claude::model_config::get_model_registry;
2392
2393 println!("๐ค AI Model Configuration:");
2394
2395 let metadata = client.get_ai_client_metadata();
2397 let registry = get_model_registry();
2398
2399 if let Some(spec) = registry.get_model_spec(&metadata.model) {
2400 if metadata.model != spec.api_identifier {
2402 println!(
2403 " ๐ก Model: {} โ \x1b[33m{}\x1b[0m",
2404 metadata.model, spec.api_identifier
2405 );
2406 } else {
2407 println!(" ๐ก Model: \x1b[33m{}\x1b[0m", metadata.model);
2408 }
2409
2410 println!(" ๐ท๏ธ Provider: {}", spec.provider);
2411 println!(" ๐ Generation: {}", spec.generation);
2412 println!(" โญ Tier: {} ({})", spec.tier, {
2413 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
2414 &tier_info.description
2415 } else {
2416 "No description available"
2417 }
2418 });
2419 println!(" ๐ค Max output tokens: {}", spec.max_output_tokens);
2420 println!(" ๐ฅ Input context: {}", spec.input_context);
2421
2422 if spec.legacy {
2423 println!(" โ ๏ธ Legacy model (consider upgrading to newer version)");
2424 }
2425 } else {
2426 println!(" ๐ก Model: \x1b[33m{}\x1b[0m", metadata.model);
2428 println!(" ๐ท๏ธ Provider: {}", metadata.provider);
2429 println!(" โ ๏ธ Model not found in registry, using client metadata:");
2430 println!(" ๐ค Max output tokens: {}", metadata.max_response_length);
2431 println!(" ๐ฅ Input context: {}", metadata.max_context_length);
2432 }
2433
2434 println!();
2435 Ok(())
2436 }
2437}