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 if use_contextual && context.is_some() {
313 println!("๐ค Analyzing commits with enhanced contextual intelligence...");
314 } else {
315 println!("๐ค Analyzing commits with Claude AI...");
316 }
317
318 let amendments = if let Some(ctx) = context {
319 claude_client
320 .generate_contextual_amendments(&full_repo_view, &ctx)
321 .await?
322 } else {
323 claude_client.generate_amendments(&full_repo_view).await?
324 };
325
326 if let Some(save_path) = self.save_only {
328 amendments.save_to_file(save_path)?;
329 println!("๐พ Amendments saved to file");
330 return Ok(());
331 }
332
333 if !amendments.amendments.is_empty() {
335 let temp_dir = tempfile::tempdir()?;
337 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
338 amendments.save_to_file(&amendments_file)?;
339
340 if !self.auto_apply && !self.handle_amendments_file(&amendments_file, &amendments)? {
342 println!("โ Amendment cancelled by user");
343 return Ok(());
344 }
345
346 self.apply_amendments(amendments).await?;
348 println!("โ
Commit messages improved successfully!");
349 } else {
350 println!("โจ All commit messages are already well-formatted!");
351 }
352
353 Ok(())
354 }
355
356 async fn execute_with_batching(
358 &self,
359 use_contextual: bool,
360 full_repo_view: crate::data::RepositoryView,
361 ) -> Result<()> {
362 use crate::data::amendments::AmendmentFile;
363
364 let claude_client = crate::claude::create_default_claude_client(self.model.clone())?;
366
367 let commit_batches: Vec<_> = full_repo_view.commits.chunks(self.batch_size).collect();
369
370 let total_batches = commit_batches.len();
371 let mut all_amendments = AmendmentFile {
372 amendments: Vec::new(),
373 };
374
375 println!("๐ Processing {} batches...", total_batches);
376
377 for (batch_num, commit_batch) in commit_batches.into_iter().enumerate() {
378 println!(
379 "๐ Processing batch {}/{} ({} commits)...",
380 batch_num + 1,
381 total_batches,
382 commit_batch.len()
383 );
384
385 let batch_repo_view = crate::data::RepositoryView {
387 versions: full_repo_view.versions.clone(),
388 explanation: full_repo_view.explanation.clone(),
389 working_directory: full_repo_view.working_directory.clone(),
390 remotes: full_repo_view.remotes.clone(),
391 ai: full_repo_view.ai.clone(),
392 branch_info: full_repo_view.branch_info.clone(),
393 pr_template: full_repo_view.pr_template.clone(),
394 branch_prs: full_repo_view.branch_prs.clone(),
395 commits: commit_batch.to_vec(),
396 };
397
398 let batch_context = if use_contextual {
400 Some(self.collect_context(&batch_repo_view).await?)
401 } else {
402 None
403 };
404
405 let batch_amendments = if let Some(ctx) = batch_context {
407 claude_client
408 .generate_contextual_amendments(&batch_repo_view, &ctx)
409 .await?
410 } else {
411 claude_client.generate_amendments(&batch_repo_view).await?
412 };
413
414 all_amendments
416 .amendments
417 .extend(batch_amendments.amendments);
418
419 if batch_num + 1 < total_batches {
420 println!(" โ
Batch {}/{} completed", batch_num + 1, total_batches);
421 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
423 }
424 }
425
426 println!(
427 "โ
All batches completed! Found {} commits to improve.",
428 all_amendments.amendments.len()
429 );
430
431 if let Some(save_path) = &self.save_only {
433 all_amendments.save_to_file(save_path)?;
434 println!("๐พ Amendments saved to file");
435 return Ok(());
436 }
437
438 if !all_amendments.amendments.is_empty() {
440 let temp_dir = tempfile::tempdir()?;
442 let amendments_file = temp_dir.path().join("twiddle_amendments.yaml");
443 all_amendments.save_to_file(&amendments_file)?;
444
445 if !self.auto_apply
447 && !self.handle_amendments_file(&amendments_file, &all_amendments)?
448 {
449 println!("โ Amendment cancelled by user");
450 return Ok(());
451 }
452
453 self.apply_amendments(all_amendments).await?;
455 println!("โ
Commit messages improved successfully!");
456 } else {
457 println!("โจ All commit messages are already well-formatted!");
458 }
459
460 Ok(())
461 }
462
463 async fn generate_repository_view(&self) -> Result<crate::data::RepositoryView> {
465 use crate::data::{
466 AiInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
467 WorkingDirectoryInfo,
468 };
469 use crate::git::{GitRepository, RemoteInfo};
470 use crate::utils::ai_scratch;
471
472 let commit_range = self.commit_range.as_deref().unwrap_or("HEAD~5..HEAD");
473
474 let repo = GitRepository::open()
476 .context("Failed to open git repository. Make sure you're in a git repository.")?;
477
478 let wd_status = repo.get_working_directory_status()?;
480 let working_directory = WorkingDirectoryInfo {
481 clean: wd_status.clean,
482 untracked_changes: wd_status
483 .untracked_changes
484 .into_iter()
485 .map(|fs| FileStatusInfo {
486 status: fs.status,
487 file: fs.file,
488 })
489 .collect(),
490 };
491
492 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
494
495 let commits = repo.get_commits_in_range(commit_range)?;
497
498 let versions = Some(VersionInfo {
500 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
501 });
502
503 let ai_scratch_path =
505 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
506 let ai_info = AiInfo {
507 scratch: ai_scratch_path.to_string_lossy().to_string(),
508 };
509
510 let mut repo_view = RepositoryView {
512 versions,
513 explanation: FieldExplanation::default(),
514 working_directory,
515 remotes,
516 ai: ai_info,
517 branch_info: None,
518 pr_template: None,
519 branch_prs: None,
520 commits,
521 };
522
523 repo_view.update_field_presence();
525
526 Ok(repo_view)
527 }
528
529 fn handle_amendments_file(
531 &self,
532 amendments_file: &std::path::Path,
533 amendments: &crate::data::amendments::AmendmentFile,
534 ) -> Result<bool> {
535 use std::io::{self, Write};
536
537 println!(
538 "\n๐ Found {} commits that could be improved.",
539 amendments.amendments.len()
540 );
541 println!("๐พ Amendments saved to: {}", amendments_file.display());
542 println!();
543
544 loop {
545 print!("โ [A]pply amendments, [S]how file, or [Q]uit? [A/s/q] ");
546 io::stdout().flush()?;
547
548 let mut input = String::new();
549 io::stdin().read_line(&mut input)?;
550
551 match input.trim().to_lowercase().as_str() {
552 "a" | "apply" | "" => return Ok(true),
553 "s" | "show" => {
554 self.show_amendments_file(amendments_file)?;
555 println!();
556 }
557 "q" | "quit" => return Ok(false),
558 _ => {
559 println!(
560 "Invalid choice. Please enter 'a' to apply, 's' to show, or 'q' to quit."
561 );
562 }
563 }
564 }
565 }
566
567 fn show_amendments_file(&self, amendments_file: &std::path::Path) -> Result<()> {
569 use std::fs;
570
571 println!("\n๐ Amendments file contents:");
572 println!("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ");
573
574 let contents =
575 fs::read_to_string(amendments_file).context("Failed to read amendments file")?;
576
577 println!("{}", contents);
578 println!("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ");
579
580 Ok(())
581 }
582
583 async fn apply_amendments(
585 &self,
586 amendments: crate::data::amendments::AmendmentFile,
587 ) -> Result<()> {
588 use crate::git::AmendmentHandler;
589
590 let temp_dir = tempfile::tempdir()?;
592 let temp_file = temp_dir.path().join("twiddle_amendments.yaml");
593 amendments.save_to_file(&temp_file)?;
594
595 let handler = AmendmentHandler::new().context("Failed to initialize amendment handler")?;
597 handler
598 .apply_amendments(&temp_file.to_string_lossy())
599 .context("Failed to apply amendments")?;
600
601 Ok(())
602 }
603
604 async fn collect_context(
606 &self,
607 repo_view: &crate::data::RepositoryView,
608 ) -> Result<crate::data::context::CommitContext> {
609 use crate::claude::context::{BranchAnalyzer, ProjectDiscovery, WorkPatternAnalyzer};
610 use crate::data::context::CommitContext;
611 use crate::git::GitRepository;
612
613 let mut context = CommitContext::new();
614
615 let context_dir = self
617 .context_dir
618 .as_ref()
619 .cloned()
620 .unwrap_or_else(|| std::path::PathBuf::from(".omni-dev"));
621
622 let repo_root = std::path::PathBuf::from(".");
624 let discovery = ProjectDiscovery::new(repo_root, context_dir.clone());
625 debug!(context_dir = ?context_dir, "Using context directory");
626 match discovery.discover() {
627 Ok(project_context) => {
628 debug!("Discovery successful");
629 context.project = project_context;
630 }
631 Err(e) => {
632 debug!(error = %e, "Discovery failed");
633 context.project = Default::default();
634 }
635 }
636
637 let repo = GitRepository::open()?;
639 let current_branch = repo
640 .get_current_branch()
641 .unwrap_or_else(|_| "HEAD".to_string());
642 context.branch = BranchAnalyzer::analyze(¤t_branch).unwrap_or_default();
643
644 if !repo_view.commits.is_empty() {
646 context.range = WorkPatternAnalyzer::analyze_commit_range(&repo_view.commits);
647 }
648
649 if let Some(ref work_ctx) = self.work_context {
651 context.user_provided = Some(work_ctx.clone());
652 }
653
654 if let Some(ref branch_ctx) = self.branch_context {
655 context.branch.description = branch_ctx.clone();
656 }
657
658 Ok(context)
659 }
660
661 fn show_context_summary(&self, context: &crate::data::context::CommitContext) -> Result<()> {
663 use crate::data::context::{VerbosityLevel, WorkPattern};
664
665 println!("๐ Context Analysis:");
666
667 if !context.project.valid_scopes.is_empty() {
669 let scope_names: Vec<&str> = context
670 .project
671 .valid_scopes
672 .iter()
673 .map(|s| s.name.as_str())
674 .collect();
675 println!(" ๐ Valid scopes: {}", scope_names.join(", "));
676 }
677
678 if context.branch.is_feature_branch {
680 println!(
681 " ๐ฟ Branch: {} ({})",
682 context.branch.description, context.branch.work_type
683 );
684 if let Some(ref ticket) = context.branch.ticket_id {
685 println!(" ๐ซ Ticket: {}", ticket);
686 }
687 }
688
689 match context.range.work_pattern {
691 WorkPattern::Sequential => println!(" ๐ Pattern: Sequential development"),
692 WorkPattern::Refactoring => println!(" ๐งน Pattern: Refactoring work"),
693 WorkPattern::BugHunt => println!(" ๐ Pattern: Bug investigation"),
694 WorkPattern::Documentation => println!(" ๐ Pattern: Documentation updates"),
695 WorkPattern::Configuration => println!(" โ๏ธ Pattern: Configuration changes"),
696 WorkPattern::Unknown => {}
697 }
698
699 match context.suggested_verbosity() {
701 VerbosityLevel::Comprehensive => {
702 println!(" ๐ Detail level: Comprehensive (significant changes detected)")
703 }
704 VerbosityLevel::Detailed => println!(" ๐ Detail level: Detailed"),
705 VerbosityLevel::Concise => println!(" ๐ Detail level: Concise"),
706 }
707
708 if let Some(ref user_ctx) = context.user_provided {
710 println!(" ๐ค User context: {}", user_ctx);
711 }
712
713 println!();
714 Ok(())
715 }
716}
717
718impl BranchCommand {
719 pub fn execute(self) -> Result<()> {
721 match self.command {
722 BranchSubcommands::Info(info_cmd) => info_cmd.execute(),
723 }
724 }
725}
726
727impl InfoCommand {
728 pub fn execute(self) -> Result<()> {
730 use crate::data::{
731 AiInfo, BranchInfo, FieldExplanation, FileStatusInfo, RepositoryView, VersionInfo,
732 WorkingDirectoryInfo,
733 };
734 use crate::git::{GitRepository, RemoteInfo};
735 use crate::utils::ai_scratch;
736
737 let repo = GitRepository::open()
739 .context("Failed to open git repository. Make sure you're in a git repository.")?;
740
741 let current_branch = repo.get_current_branch().context(
743 "Failed to get current branch. Make sure you're not in detached HEAD state.",
744 )?;
745
746 let base_branch = match self.base_branch {
748 Some(branch) => {
749 if !repo.branch_exists(&branch)? {
751 anyhow::bail!("Base branch '{}' does not exist", branch);
752 }
753 branch
754 }
755 None => {
756 if repo.branch_exists("main")? {
758 "main".to_string()
759 } else if repo.branch_exists("master")? {
760 "master".to_string()
761 } else {
762 anyhow::bail!("No default base branch found (main or master)");
763 }
764 }
765 };
766
767 let commit_range = format!("{}..HEAD", base_branch);
769
770 let wd_status = repo.get_working_directory_status()?;
772 let working_directory = WorkingDirectoryInfo {
773 clean: wd_status.clean,
774 untracked_changes: wd_status
775 .untracked_changes
776 .into_iter()
777 .map(|fs| FileStatusInfo {
778 status: fs.status,
779 file: fs.file,
780 })
781 .collect(),
782 };
783
784 let remotes = RemoteInfo::get_all_remotes(repo.repository())?;
786
787 let commits = repo.get_commits_in_range(&commit_range)?;
789
790 let pr_template = Self::read_pr_template().ok();
792
793 let branch_prs = Self::get_branch_prs(¤t_branch)
795 .ok()
796 .filter(|prs| !prs.is_empty());
797
798 let versions = Some(VersionInfo {
800 omni_dev: env!("CARGO_PKG_VERSION").to_string(),
801 });
802
803 let ai_scratch_path =
805 ai_scratch::get_ai_scratch_dir().context("Failed to determine AI scratch directory")?;
806 let ai_info = AiInfo {
807 scratch: ai_scratch_path.to_string_lossy().to_string(),
808 };
809
810 let mut repo_view = RepositoryView {
812 versions,
813 explanation: FieldExplanation::default(),
814 working_directory,
815 remotes,
816 ai: ai_info,
817 branch_info: Some(BranchInfo {
818 branch: current_branch,
819 }),
820 pr_template,
821 branch_prs,
822 commits,
823 };
824
825 repo_view.update_field_presence();
827
828 let yaml_output = crate::data::to_yaml(&repo_view)?;
830 println!("{}", yaml_output);
831
832 Ok(())
833 }
834
835 fn read_pr_template() -> Result<String> {
837 use std::fs;
838 use std::path::Path;
839
840 let template_path = Path::new(".github/pull_request_template.md");
841 if template_path.exists() {
842 fs::read_to_string(template_path)
843 .context("Failed to read .github/pull_request_template.md")
844 } else {
845 anyhow::bail!("PR template file does not exist")
846 }
847 }
848
849 fn get_branch_prs(branch_name: &str) -> Result<Vec<crate::data::PullRequest>> {
851 use serde_json::Value;
852 use std::process::Command;
853
854 let output = Command::new("gh")
856 .args([
857 "pr",
858 "list",
859 "--head",
860 branch_name,
861 "--json",
862 "number,title,state,url,body",
863 "--limit",
864 "50",
865 ])
866 .output()
867 .context("Failed to execute gh command")?;
868
869 if !output.status.success() {
870 anyhow::bail!(
871 "gh command failed: {}",
872 String::from_utf8_lossy(&output.stderr)
873 );
874 }
875
876 let json_str = String::from_utf8_lossy(&output.stdout);
877 let prs_json: Value =
878 serde_json::from_str(&json_str).context("Failed to parse PR JSON from gh")?;
879
880 let mut prs = Vec::new();
881 if let Some(prs_array) = prs_json.as_array() {
882 for pr_json in prs_array {
883 if let (Some(number), Some(title), Some(state), Some(url), Some(body)) = (
884 pr_json.get("number").and_then(|n| n.as_u64()),
885 pr_json.get("title").and_then(|t| t.as_str()),
886 pr_json.get("state").and_then(|s| s.as_str()),
887 pr_json.get("url").and_then(|u| u.as_str()),
888 pr_json.get("body").and_then(|b| b.as_str()),
889 ) {
890 prs.push(crate::data::PullRequest {
891 number,
892 title: title.to_string(),
893 state: state.to_string(),
894 url: url.to_string(),
895 body: body.to_string(),
896 });
897 }
898 }
899 }
900
901 Ok(prs)
902 }
903}