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