1use anyhow::{Context, Result};
4use clap::{Parser, Subcommand};
5use tracing::debug;
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
118#[derive(Parser)]
120pub struct BranchCommand {
121 #[command(subcommand)]
123 pub command: BranchSubcommands,
124}
125
126#[derive(Subcommand)]
128pub enum BranchSubcommands {
129 Info(InfoCommand),
131}
132
133#[derive(Parser)]
135pub struct InfoCommand {
136 #[arg(value_name = "BASE_BRANCH")]
138 pub base_branch: Option<String>,
139}
140
141impl GitCommand {
142 pub fn execute(self) -> Result<()> {
144 match self.command {
145 GitSubcommands::Commit(commit_cmd) => commit_cmd.execute(),
146 GitSubcommands::Branch(branch_cmd) => branch_cmd.execute(),
147 }
148 }
149}
150
151impl CommitCommand {
152 pub fn execute(self) -> Result<()> {
154 match self.command {
155 CommitSubcommands::Message(message_cmd) => message_cmd.execute(),
156 }
157 }
158}
159
160impl MessageCommand {
161 pub fn execute(self) -> Result<()> {
163 match self.command {
164 MessageSubcommands::View(view_cmd) => view_cmd.execute(),
165 MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(),
166 MessageSubcommands::Twiddle(twiddle_cmd) => {
167 let rt =
169 tokio::runtime::Runtime::new().context("Failed to create tokio runtime")?;
170 rt.block_on(twiddle_cmd.execute())
171 }
172 }
173 }
174}
175
176impl ViewCommand {
177 pub fn execute(self) -> Result<()> {
179 use crate::data::{
180 AiInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
181 WorkingDirectoryInfo,
182 };
183 use crate::git::{GitRepository, RemoteInfo};
184 use crate::utils::ai_scratch;
185
186 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD");
187
188 let repo = GitRepository::open()
190 .context("Failed to open git repository. Make sure you're in a git repository.")?;
191
192 let wd_status = repo.get_working_directory_status()?;
194 let working_directory = WorkingDirectoryInfo {
195 clean: wd_status.clean,
196 untracked_changes: wd_status
197 .untracked_changes
198 .into_iter()
199 .map(|fs| FileStatusInfo {
200 status: fs.status,
201 file: fs.file,
202 })
203 .collect(),
204 };
205
206 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
208
209 let commits = repo.get_commits_in_range(commit_range)?;
211
212 let versions = Some(VersionInfo {
214 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
215 });
216
217 let ai_scratch_path =
219 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
220 let ai_info = AiInfo {
221 scratch: ai_scratch_path.to_string_lossy().to_string(),
222 };
223
224 let mut repo_view = RepositoryView {
226 versions,
227 explanation: FieldExplanation::default(),
228 working_directory,
229 remotes,
230 ai: ai_info,
231 branch_info: None,
232 pr_template: None,
233 branch_prs: None,
234 commits,
235 };
236
237 repo_view.update_field_presence();
239
240 let yaml_output = crate::data::to_yaml(&repo_view)?;
242 println!("{}", yaml_output);
243
244 Ok(())
245 }
246}
247
248impl AmendCommand {
249 pub fn execute(self) -> Result<()> {
251 use crate::git::AmendmentHandler;
252
253 println!("๐ Starting commit amendment process...");
254 println!("๐ Loading amendments from: {}", self.yaml_file);
255
256 let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
258
259 handler
260 .apply_amendments(&self.yaml_file)
261 .context("Failed to apply amendments")?;
262
263 Ok(())
264 }
265}
266
267impl TwiddleCommand {
268 pub async fn execute(self) -> Result<()> {
270 let use_contextual = self.use_context && !self.no_context;
272
273 if use_contextual {
274 println!(
275 "๐ช Starting AI-powered commit message improvement with contextual intelligence..."
276 );
277 } else {
278 println!("๐ช Starting AI-powered commit message improvement...");
279 }
280
281 let full_repo_view = self.generate_repository_view().await?;
283
284 if full_repo_view.commits.len() > self.batch_size {
286 println!(
287 "๐ฆ Processing {} commits in batches of {} to ensure reliable analysis...",
288 full_repo_view.commits.len(),
289 self.batch_size
290 );
291 return self
292 .execute_with_batching(use_contextual, full_repo_view)
293 .await;
294 }
295
296 let context = if use_contextual {
298 Some(self.collect_context(&full_repo_view).await?)
299 } else {
300 None
301 };
302
303 if let Some(ref ctx) = context {
305 self.show_context_summary(ctx)?;
306 }
307
308 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
310
311 self.show_model_info()?;
313
314 if use_contextual && context.is_some() {
316 println!("๐ค Analyzing commits with enhanced contextual intelligence...");
317 } else {
318 println!("๐ค Analyzing commits with Claude AI...");
319 }
320
321 let amendments = if let Some(ctx) = context {
322 claude_client
323 .generate_contextual_amendments(&full_repo_view, &ctx)
324 .await?
325 } else {
326 claude_client.generate_amendments(&full_repo_view).await?
327 };
328
329 if let Some(save_path) = self.save_only {
331 amendments.save_to_file(save_path)?;
332 println!("๐พ Amendments saved to file");
333 return Ok(());
334 }
335
336 if !amendments.amendments.is_empty() {
338 let temp_dir = tempfile::tempdir()?;
340 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
341 amendments.save_to_file(&amendments_file)?;
342
343 if !self.auto_apply && !self.handle_amendments_file(&amendments_file, &amendments)? {
345 println!("โ Amendment cancelled by user");
346 return Ok(());
347 }
348
349 self.apply_amendments(amendments).await?;
351 println!("โ
Commit messages improved successfully!");
352 } else {
353 println!("โจ All commit messages are already well-formatted!");
354 }
355
356 Ok(())
357 }
358
359 async fn execute_with_batching(
361 &self,
362 use_contextual: bool,
363 full_repo_view: crate::data::RepositoryView,
364 ) -> Result<()> {
365 use crate::data::amendments::AmendmentFile;
366
367 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
369
370 self.show_model_info()?;
372
373 let commit_batches: Vec<_> = full_repo_view.commits.chunks(self.batch_size).collect();
375
376 let total_batches = commit_batches.len();
377 let mut all_amendments = AmendmentFile {
378 amendments: Vec::new(),
379 };
380
381 println!("๐ Processing {} batches...", total_batches);
382
383 for (batch_num, commit_batch) in commit_batches.into_iter().enumerate() {
384 println!(
385 "๐ Processing batch {}/{} ({} commits)...",
386 batch_num + 1,
387 total_batches,
388 commit_batch.len()
389 );
390
391 let batch_repo_view = crate::data::RepositoryView {
393 versions: full_repo_view.versions.clone(),
394 explanation: full_repo_view.explanation.clone(),
395 working_directory: full_repo_view.working_directory.clone(),
396 remotes: full_repo_view.remotes.clone(),
397 ai: full_repo_view.ai.clone(),
398 branch_info: full_repo_view.branch_info.clone(),
399 pr_template: full_repo_view.pr_template.clone(),
400 branch_prs: full_repo_view.branch_prs.clone(),
401 commits: commit_batch.to_vec(),
402 };
403
404 let batch_context = if use_contextual {
406 Some(self.collect_context(&batch_repo_view).await?)
407 } else {
408 None
409 };
410
411 let batch_amendments = if let Some(ctx) = batch_context {
413 claude_client
414 .generate_contextual_amendments(&batch_repo_view, &ctx)
415 .await?
416 } else {
417 claude_client.generate_amendments(&batch_repo_view).await?
418 };
419
420 all_amendments
422 .amendments
423 .extend(batch_amendments.amendments);
424
425 if batch_num + 1 < total_batches {
426 println!(" โ
Batch {}/{} completed", batch_num + 1, total_batches);
427 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
429 }
430 }
431
432 println!(
433 "โ
All batches completed! Found {} commits to improve.",
434 all_amendments.amendments.len()
435 );
436
437 if let Some(save_path) = &self.save_only {
439 all_amendments.save_to_file(save_path)?;
440 println!("๐พ Amendments saved to file");
441 return Ok(());
442 }
443
444 if !all_amendments.amendments.is_empty() {
446 let temp_dir = tempfile::tempdir()?;
448 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
449 all_amendments.save_to_file(&amendments_file)?;
450
451 if !self.auto_apply
453 && !self.handle_amendments_file(&amendments_file, &all_amendments)?
454 {
455 println!("โ Amendment cancelled by user");
456 return Ok(());
457 }
458
459 self.apply_amendments(all_amendments).await?;
461 println!("โ
Commit messages improved successfully!");
462 } else {
463 println!("โจ All commit messages are already well-formatted!");
464 }
465
466 Ok(())
467 }
468
469 async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
471 use crate::data::{
472 AiInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
473 WorkingDirectoryInfo,
474 };
475 use crate::git::{GitRepository, RemoteInfo};
476 use crate::utils::ai_scratch;
477
478 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
479
480 let repo = GitRepository::open()
482 .context("Failed to open git repository. Make sure you're in a git repository.")?;
483
484 let wd_status = repo.get_working_directory_status()?;
486 let working_directory = WorkingDirectoryInfo {
487 clean: wd_status.clean,
488 untracked_changes: wd_status
489 .untracked_changes
490 .into_iter()
491 .map(|fs| FileStatusInfo {
492 status: fs.status,
493 file: fs.file,
494 })
495 .collect(),
496 };
497
498 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
500
501 let commits = repo.get_commits_in_range(commit_range)?;
503
504 let versions = Some(VersionInfo {
506 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
507 });
508
509 let ai_scratch_path =
511 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
512 let ai_info = AiInfo {
513 scratch: ai_scratch_path.to_string_lossy().to_string(),
514 };
515
516 let mut repo_view = RepositoryView {
518 versions,
519 explanation: FieldExplanation::default(),
520 working_directory,
521 remotes,
522 ai: ai_info,
523 branch_info: None,
524 pr_template: None,
525 branch_prs: None,
526 commits,
527 };
528
529 repo_view.update_field_presence();
531
532 Ok(repo_view)
533 }
534
535 fn handle_amendments_file(
537 &self,
538 amendments_file: &std::path::Path,
539 amendments: &crate::data::amendments::AmendmentFile,
540 ) -> Result<bool> {
541 use std::io::{self, Write};
542
543 println!(
544 "\n๐ Found {} commits that could be improved.",
545 amendments.amendments.len()
546 );
547 println!("๐พ Amendments saved to: {}", amendments_file.display());
548 println!();
549
550 loop {
551 print!("โ [A]pply amendments, [S]how file, [E]dit file, or [Q]uit? [A/s/e/q] ");
552 io::stdout().flush()?;
553
554 let mut input = String::new();
555 io::stdin().read_line(&mut input)?;
556
557 match input.trim().to_lowercase().as_str() {
558 "a" | "apply" | "" => return Ok(true),
559 "s" | "show" => {
560 self.show_amendments_file(amendments_file)?;
561 println!();
562 }
563 "e" | "edit" => {
564 self.edit_amendments_file(amendments_file)?;
565 println!();
566 }
567 "q" | "quit" => return Ok(false),
568 _ => {
569 println!(
570 "Invalid choice. Please enter 'a' to apply, 's' to show, 'e' to edit, or 'q' to quit."
571 );
572 }
573 }
574 }
575 }
576
577 fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
579 use std::fs;
580
581 println!("\n๐ Amendments file contents:");
582 println!("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ");
583
584 let contents =
585 fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
586
587 println!("{}", contents);
588 println!("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ");
589
590 Ok(())
591 }
592
593 fn edit_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
595 use std::env;
596 use std::io::{self, Write};
597 use std::process::Command;
598
599 let editor = env::var("OMNI_DEV_EDITOR")
601 .or_else(|_| env::var("EDITOR"))
602 .unwrap_or_else(|_| {
603 println!(
605 "๐ง Neither OMNI_DEV_EDITOR nor EDITOR environment variables are defined."
606 );
607 print!("Please enter the command to use as your editor: ");
608 io::stdout().flush().expect("Failed to flush stdout");
609
610 let mut input = String::new();
611 io::stdin()
612 .read_line(&mut input)
613 .expect("Failed to read user input");
614 input.trim().to_string()
615 });
616
617 if editor.is_empty() {
618 println!("โ No editor specified. Returning to menu.");
619 return Ok(());
620 }
621
622 println!("๐ Opening amendments file in editor: {}", editor);
623
624 let mut cmd_parts = editor.split_whitespace();
626 let editor_cmd = cmd_parts.next().unwrap_or(&editor);
627 let args: Vec<&str> = cmd_parts.collect();
628
629 let mut command = Command::new(editor_cmd);
630 command.args(args);
631 command.arg(amendments_file.to_string_lossy().as_ref());
632
633 match command.status() {
634 Ok(status) => {
635 if status.success() {
636 println!("โ
Editor session completed.");
637 } else {
638 println!(
639 "โ ๏ธ Editor exited with non-zero status: {:?}",
640 status.code()
641 );
642 }
643 }
644 Err(e) => {
645 println!("โ Failed to execute editor '{}': {}", editor, e);
646 println!(" Please check that the editor command is correct and available in your PATH.");
647 }
648 }
649
650 Ok(())
651 }
652
653 async fn apply_amendments(
655 &self,
656 amendments: crate::data::amendments::AmendmentFile,
657 ) -> Result<()> {
658 use crate::git::AmendmentHandler;
659
660 let temp_dir = tempfile::tempdir()?;
662 let temp_file = temp_dir.path().join("twiddle_amendments.yaml");
663 amendments.save_to_file(&temp_file)?;
664
665 let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
667 handler
668 .apply_amendments(&temp_file.to_string_lossy())
669 .context("Failed to apply amendments")?;
670
671 Ok(())
672 }
673
674 async fn collect_context(
676 &self,
677 repo_view: &crate::data::RepositoryView,
678 ) -> Result<crate::data::context::CommitContext> {
679 use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
680 use crate::data::context::CommitContext;
681 use crate::git::GitRepository;
682
683 let mut context = CommitContext::new();
684
685 let context_dir = self
687 .context_dir
688 .as_ref()
689 .cloned()
690 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
691
692 let repo_root = std::path::PathBuf::from(".");
694 let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
695 debug!(context_dir = ?context_dir, "Using context directory");
696 match discovery.discover() {
697 Ok(project_context) => {
698 debug!("Discovery successful");
699
700 self.show_guidance_files_status(&project_context, &context_dir)?;
702
703 context.project = project_context;
704 }
705 Err(e) => {
706 debug!(error = %e, "Discovery failed");
707 context.project = Default::default();
708 }
709 }
710
711 let repo = GitRepository::open()?;
713 let current_branch = repo
714 .get_current_branch()
715 .unwrap_or_else(|_| "HEAD".to_string());
716 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
717
718 if !repo_view.commits.is_empty() {
720 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
721 }
722
723 if let Some(ref work_ctx) = self.work_context {
725 context.user_provided = Some(work_ctx.clone());
726 }
727
728 if let Some(ref branch_ctx) = self.branch_context {
729 context.branch.description = branch_ctx.clone();
730 }
731
732 Ok(context)
733 }
734
735 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
737 use crate::data::context::{VerbosityLevel, WorkPattern};
738
739 println!("๐ Context Analysis:");
740
741 if !context.project.valid_scopes.is_empty() {
743 let scope_names: Vec<&str> = context
744 .project
745 .valid_scopes
746 .iter()
747 .map(|s| s.name.as_str())
748 .collect();
749 println!(" ๐ Valid scopes: {}", scope_names.join(", "));
750 }
751
752 if context.branch.is_feature_branch {
754 println!(
755 " ๐ฟ Branch: {} ({})",
756 context.branch.description, context.branch.work_type
757 );
758 if let Some(ref ticket) = context.branch.ticket_id {
759 println!(" ๐ซ Ticket: {}", ticket);
760 }
761 }
762
763 match context.range.work_pattern {
765 WorkPattern::Sequential => println!(" ๐ Pattern: Sequential development"),
766 WorkPattern::Refactoring => println!(" ๐งน Pattern: Refactoring work"),
767 WorkPattern::BugHunt => println!(" ๐ Pattern: Bug investigation"),
768 WorkPattern::Documentation => println!(" ๐ Pattern: Documentation updates"),
769 WorkPattern::Configuration => println!(" โ๏ธ Pattern: Configuration changes"),
770 WorkPattern::Unknown => {}
771 }
772
773 match context.suggested_verbosity() {
775 VerbosityLevel::Comprehensive => {
776 println!(" ๐ Detail level: Comprehensive (significant changes detected)")
777 }
778 VerbosityLevel::Detailed => println!(" ๐ Detail level: Detailed"),
779 VerbosityLevel::Concise => println!(" ๐ Detail level: Concise"),
780 }
781
782 if let Some(ref user_ctx) = context.user_provided {
784 println!(" ๐ค User context: {}", user_ctx);
785 }
786
787 println!();
788 Ok(())
789 }
790
791 fn show_model_info(&self) -> Result<()> {
793 use crate::claude::model_config::get_model_registry;
794
795 let model_name = self
797 .model
798 .clone()
799 .or_else(|| crate::utils::settings::get_env_var("ANTHROPIC_MODEL").ok())
800 .unwrap_or_else(|| "claude-3-haiku-20240307".to_string());
801
802 println!("๐ค AI Model Configuration:");
803
804 let registry = get_model_registry();
806 if let Some(spec) = registry.get_model_spec(&model_name) {
807 if model_name != spec.api_identifier {
809 println!(
810 " ๐ก Model: {} โ \x1b[33m{}\x1b[0m",
811 model_name, spec.api_identifier
812 );
813 } else {
814 println!(" ๐ก Model: \x1b[33m{}\x1b[0m", model_name);
815 }
816
817 println!(" ๐ท๏ธ Provider: {}", spec.provider);
818 println!(" ๐ Generation: {}", spec.generation);
819 println!(" โญ Tier: {} ({})", spec.tier, {
820 if let Some(tier_info) = registry.get_tier_info(&spec.provider, &spec.tier) {
821 &tier_info.description
822 } else {
823 "No description available"
824 }
825 });
826 println!(" ๐ค Max output tokens: {}", spec.max_output_tokens);
827 println!(" ๐ฅ Input context: {}", spec.input_context);
828
829 if spec.legacy {
830 println!(" โ ๏ธ Legacy model (consider upgrading to newer version)");
831 }
832 } else {
833 let max_tokens = registry.get_max_output_tokens(&model_name);
835 let input_context = registry.get_input_context(&model_name);
836
837 println!(" ๐ก Model: \x1b[33m{}\x1b[0m", model_name);
838 println!(" โ ๏ธ Model not found in registry, using defaults:");
839 println!(" ๐ค Max output tokens: {}", max_tokens);
840 println!(" ๐ฅ Input context: {}", input_context);
841 }
842
843 println!();
844 Ok(())
845 }
846
847 fn show_guidance_files_status(
849 &self,
850 project_context: &crate::data::context::ProjectContext,
851 context_dir: &std::path::Path,
852 ) -> Result<()> {
853 println!("๐ Project guidance files status:");
854
855 let guidelines_found = project_context.commit_guidelines.is_some();
857 let guidelines_source = if guidelines_found {
858 let local_path = context_dir.join("local").join("commit-guidelines.md");
859 let project_path = context_dir.join("commit-guidelines.md");
860 let home_path = dirs::home_dir()
861 .map(|h| h.join(".omni-dev").join("commit-guidelines.md"))
862 .unwrap_or_default();
863
864 if local_path.exists() {
865 format!("โ
Local override: {}", local_path.display())
866 } else if project_path.exists() {
867 format!("โ
Project: {}", project_path.display())
868 } else if home_path.exists() {
869 format!("โ
Global: {}", home_path.display())
870 } else {
871 "โ
(source unknown)".to_string()
872 }
873 } else {
874 "โ None found".to_string()
875 };
876 println!(" ๐ Commit guidelines: {}", guidelines_source);
877
878 let scopes_count = project_context.valid_scopes.len();
880 let scopes_source = if scopes_count > 0 {
881 let local_path = context_dir.join("local").join("scopes.yaml");
882 let project_path = context_dir.join("scopes.yaml");
883 let home_path = dirs::home_dir()
884 .map(|h| h.join(".omni-dev").join("scopes.yaml"))
885 .unwrap_or_default();
886
887 let source = if local_path.exists() {
888 format!("Local override: {}", local_path.display())
889 } else if project_path.exists() {
890 format!("Project: {}", project_path.display())
891 } else if home_path.exists() {
892 format!("Global: {}", home_path.display())
893 } else {
894 "(source unknown + ecosystem defaults)".to_string()
895 };
896 format!("โ
{} ({} scopes)", source, scopes_count)
897 } else {
898 "โ None found".to_string()
899 };
900 println!(" ๐ฏ Valid scopes: {}", scopes_source);
901
902 println!();
903 Ok(())
904 }
905}
906
907impl BranchCommand {
908 pub fn execute(self) -> Result<()> {
910 match self.command {
911 BranchSubcommands::Info(info_cmd) => info_cmd.execute(),
912 }
913 }
914}
915
916impl InfoCommand {
917 pub fn execute(self) -> Result<()> {
919 use crate::data::{
920 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
921 WorkingDirectoryInfo,
922 };
923 use crate::git::{GitRepository, RemoteInfo};
924 use crate::utils::ai_scratch;
925
926 let repo = GitRepository::open()
928 .context("Failed to open git repository. Make sure you're in a git repository.")?;
929
930 let current_branch = repo.get_current_branch().context(
932 "Failed to get current branch. Make sure you're not in detached HEAD state.",
933 )?;
934
935 let base_branch = match self.base_branch {
937 Some(branch) => {
938 if !repo.branch_exists(&branch)? {
940 anyhow::bail!("Base branch '{}' does not exist", branch);
941 }
942 branch
943 }
944 None => {
945 if repo.branch_exists("main")? {
947 "main".to_string()
948 } else if repo.branch_exists("master")? {
949 "master".to_string()
950 } else {
951 anyhow::bail!("No default base branch found (main or master)");
952 }
953 }
954 };
955
956 let commit_range = format!("{}..HEAD", base_branch);
958
959 let wd_status = repo.get_working_directory_status()?;
961 let working_directory = WorkingDirectoryInfo {
962 clean: wd_status.clean,
963 untracked_changes: wd_status
964 .untracked_changes
965 .into_iter()
966 .map(|fs| FileStatusInfo {
967 status: fs.status,
968 file: fs.file,
969 })
970 .collect(),
971 };
972
973 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
975
976 let commits = repo.get_commits_in_range(&commit_range)?;
978
979 let pr_template = Self::read_pr_template().ok();
981
982 let branch_prs = Self::get_branch_prs(¤t_branch)
984 .ok()
985 .filter(|prs| !prs.is_empty());
986
987 let versions = Some(VersionInfo {
989 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
990 });
991
992 let ai_scratch_path =
994 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
995 let ai_info = AiInfo {
996 scratch: ai_scratch_path.to_string_lossy().to_string(),
997 };
998
999 let mut repo_view = RepositoryView {
1001 versions,
1002 explanation: FieldExplanation::default(),
1003 working_directory,
1004 remotes,
1005 ai: ai_info,
1006 branch_info: Some(BranchInfo {
1007 branch: current_branch,
1008 }),
1009 pr_template,
1010 branch_prs,
1011 commits,
1012 };
1013
1014 repo_view.update_field_presence();
1016
1017 let yaml_output = crate::data::to_yaml(&repo_view)?;
1019 println!("{}", yaml_output);
1020
1021 Ok(())
1022 }
1023
1024 fn read_pr_template() -> Result<String> {
1026 use std::fs;
1027 use std::path::Path;
1028
1029 let template_path = Path::new(".github/pull_request_template.md");
1030 if template_path.exists() {
1031 fs::read_to_string(template_path)
1032 .context("Failed to read .github/pull_request_template.md")
1033 } else {
1034 anyhow::bail!("PR template file does not exist")
1035 }
1036 }
1037
1038 fn get_branch_prs(branch_name: &str) -> Result<Vec<crate::data::PullRequest>> {
1040 use serde_json::Value;
1041 use std::process::Command;
1042
1043 let output = Command::new("gh")
1045 .args([
1046 "pr",
1047 "list",
1048 "--head",
1049 branch_name,
1050 "--json",
1051 "number,title,state,url,body",
1052 "--limit",
1053 "50",
1054 ])
1055 .output()
1056 .context("Failed to execute gh command")?;
1057
1058 if !output.status.success() {
1059 anyhow::bail!(
1060 "gh command failed: {}",
1061 String::from_utf8_lossy(&output.stderr)
1062 );
1063 }
1064
1065 let json_str = String::from_utf8_lossy(&output.stdout);
1066 let prs_json: Value =
1067 serde_json::from_str(&json_str).context("Failed to parse PR JSON from gh")?;
1068
1069 let mut prs = Vec::new();
1070 if let Some(prs_array) = prs_json.as_array() {
1071 for pr_json in prs_array {
1072 if let (Some(number), Some(title), Some(state), Some(url), Some(body)) = (
1073 pr_json.get("number").and_then(|n| n.as_u64()),
1074 pr_json.get("title").and_then(|t| t.as_str()),
1075 pr_json.get("state").and_then(|s| s.as_str()),
1076 pr_json.get("url").and_then(|u| u.as_str()),
1077 pr_json.get("body").and_then(|b| b.as_str()),
1078 ) {
1079 prs.push(crate::data::PullRequest {
1080 number,
1081 title: title.to_string(),
1082 state: state.to_string(),
1083 url: url.to_string(),
1084 body: body.to_string(),
1085 });
1086 }
1087 }
1088 }
1089
1090 Ok(prs)
1091 }
1092}