1use anyhow::{Context, Result};
2use colored::Colorize;
3use dialoguer::{theme::ColorfulTheme, Input, MultiSelect, Select};
4use std::path::Path;
5use std::process::Command;
6
7use crate::cli::GlobalOptions;
8use crate::config::Config;
9use crate::git;
10use crate::output::progress;
11use crate::output::styling::Styling;
12use crate::providers;
13use crate::utils;
14use crate::utils::hooks::{run_hooks, write_temp_commit_file, HookOptions};
15
16const PROMPT_OVERHEAD_TOKENS: usize = 500;
20
21struct ExecContext;
23
24impl ExecContext {
25 fn new(_options: &GlobalOptions) -> Self {
26 Self
27 }
28
29 fn success(&self, message: &str) {
31 println!("{} {}", "✓".green(), message);
32 }
33
34 fn warning(&self, message: &str) {
36 eprintln!("{} {}", "!".yellow().bold(), message);
37 }
38
39 fn error(&self, message: &str) {
41 eprintln!("{} {}", "✗".red(), message);
42 }
43
44 fn header(&self, text: &str) {
46 println!("\n{}", text.bold());
47 }
48
49 fn subheader(&self, text: &str) {
51 println!("{}", text.dimmed());
52 }
53
54 fn divider(&self, length: Option<usize>) {
56 let len = length.unwrap_or(50);
57 println!("{}", Styling::divider(len));
58 }
59
60 fn key_value(&self, key: &str, value: &str) {
62 println!("{}: {}", key.dimmed(), value);
63 }
64}
65
66pub async fn execute(options: GlobalOptions) -> Result<()> {
67 let ctx = ExecContext::new(&options);
68
69 git::assert_git_repo()?;
71
72 let config = load_and_validate_config(&options)?;
74
75 let generate_count = options
77 .generate_count
78 .max(config.generate_count.unwrap_or(1))
79 .clamp(1, 5);
80
81 let (final_diff, token_count) = prepare_diff(&config, &ctx)?;
83
84 if options.show_prompt {
86 display_prompt(&config, &final_diff, options.context.as_deref(), &ctx);
87 return Ok(());
88 }
89
90 if !options.no_pre_hooks {
92 run_pre_gen_hooks(&config, token_count, options.context.as_deref())?;
93 }
94
95 let messages = generate_commit_messages(
97 &config,
98 &final_diff,
99 options.context.as_deref(),
100 options.full_gitmoji,
101 generate_count,
102 options.strip_thinking,
103 &ctx,
104 )
105 .await?;
106
107 if messages.is_empty() {
108 anyhow::bail!("Failed to generate any commit messages");
109 }
110
111 if options.clipboard {
113 return handle_clipboard_mode(&messages, &ctx);
114 }
115
116 if options.print_message {
118 print!("{}", messages[0]);
119 return Ok(());
120 }
121
122 if options.dry_run {
124 return handle_dry_run_mode(&messages, &ctx);
125 }
126
127 let mut final_message = messages[0].clone();
129 if !options.no_pre_hooks {
130 final_message = run_pre_commit_hooks(&config, &final_message)?;
131 }
132
133 display_commit_messages(&messages, &ctx);
135 handle_commit_action(&options, &config, &messages, &mut final_message, &ctx).await
136}
137
138fn load_and_validate_config(options: &GlobalOptions) -> Result<Config> {
140 let mut config = Config::load()?;
141
142 if let Some(ref prompt_file) = options.prompt_file {
144 config.set_prompt_file(Some(prompt_file.clone()));
145 }
146
147 if let Some(ref skill_name) = options.skill {
149 apply_skill_to_config(&mut config, skill_name)?;
150 }
151
152 config.load_with_commitlint()?;
153 config.apply_commitlint_rules()?;
154 Ok(config)
155}
156
157fn apply_skill_to_config(config: &mut Config, skill_name: &str) -> Result<()> {
159 use crate::skills::SkillsManager;
160
161 let mut manager = SkillsManager::new()?;
162 manager.discover()?;
163
164 let skill = manager
165 .find(skill_name)
166 .ok_or_else(|| anyhow::anyhow!(
167 "Skill '{}' not found. Run 'rco skills list' to see available skills.",
168 skill_name
169 ))?;
170
171 if let Some(prompt_template) = skill.load_prompt_template()? {
173 config.custom_prompt = Some(prompt_template);
174 tracing::info!("Loaded prompt template from skill: {}", skill_name);
175 }
176
177 println!("{} Using skill: {}", "→".cyan(), skill_name.green());
178 Ok(())
179}
180
181fn prepare_diff(config: &Config, ctx: &ExecContext) -> Result<(String, usize)> {
183 let staged_files = git::get_staged_files()?;
185 let changed_files = if staged_files.is_empty() {
186 git::get_changed_files()?
187 } else {
188 staged_files
189 };
190
191 if changed_files.is_empty() {
192 ctx.error("No changes to commit");
193 ctx.subheader("Stage some changes with 'git add' or use 'git add -A' to stage all changes");
194 anyhow::bail!("No changes to commit");
195 }
196
197 let files_to_stage = if git::get_staged_files()?.is_empty() {
199 select_files_to_stage(&changed_files)?
200 } else {
201 vec![]
202 };
203
204 if !files_to_stage.is_empty() {
206 git::stage_files(&files_to_stage)?;
207 }
208
209 let diff = git::get_staged_diff()?;
211 if diff.is_empty() {
212 ctx.error("No staged changes to commit");
213 anyhow::bail!("No staged changes to commit");
214 }
215
216 let diff = filter_diff_by_rcoignore(&diff)?;
218
219 if diff.trim().is_empty() {
221 ctx.error("No changes to commit after applying .rcoignore filters");
222 anyhow::bail!("No changes to commit after applying .rcoignore filters");
223 }
224
225 let max_tokens = config.tokens_max_input.unwrap_or(4096);
227 let token_count = utils::token::estimate_tokens(&diff)?;
228
229 let final_diff = if token_count > max_tokens {
231 ctx.warning(&format!(
232 "The diff is too large ({} tokens). Splitting into chunks...",
233 token_count
234 ));
235 chunk_diff(&diff, max_tokens)?
236 } else {
237 diff
238 };
239
240 if final_diff.trim().is_empty() {
242 anyhow::bail!(
243 "Diff is empty after processing. This may indicate all files were excluded by .rcoignore."
244 );
245 }
246
247 Ok((final_diff, token_count))
248}
249
250fn display_prompt(config: &Config, diff: &str, context: Option<&str>, ctx: &ExecContext) {
252 let prompt = config.get_effective_prompt(diff, context, false);
253 ctx.header("Prompt that would be sent to AI");
254 ctx.divider(None);
255 println!("{}", prompt);
256 ctx.divider(None);
257}
258
259fn run_pre_gen_hooks(config: &Config, token_count: usize, context: Option<&str>) -> Result<()> {
261 if let Some(hooks) = config.pre_gen_hook.clone() {
262 let envs = vec![
263 ("RCO_REPO_ROOT", git::get_repo_root()?.to_string()),
264 (
265 "RCO_MAX_TOKENS",
266 (config.tokens_max_input.unwrap_or(4096)).to_string(),
267 ),
268 ("RCO_DIFF_TOKENS", token_count.to_string()),
269 ("RCO_CONTEXT", context.unwrap_or_default().to_string()),
270 (
271 "RCO_PROVIDER",
272 config.ai_provider.clone().unwrap_or_default(),
273 ),
274 ("RCO_MODEL", config.model.clone().unwrap_or_default()),
275 ];
276 run_hooks(HookOptions {
277 name: "pre-gen",
278 commands: hooks,
279 strict: config.hook_strict.unwrap_or(true),
280 timeout: std::time::Duration::from_millis(config.hook_timeout_ms.unwrap_or(30000)),
281 envs,
282 })?;
283 }
284 Ok(())
285}
286
287fn handle_clipboard_mode(messages: &[String], ctx: &ExecContext) -> Result<()> {
289 let selected = if messages.len() == 1 {
290 0
291 } else {
292 select_message_variant(messages)?
293 };
294 copy_to_clipboard(&messages[selected])?;
295 ctx.success("Commit message copied to clipboard!");
296 Ok(())
297}
298
299fn handle_dry_run_mode(messages: &[String], ctx: &ExecContext) -> Result<()> {
301 ctx.header("Dry Run Mode - Preview");
302 ctx.divider(None);
303 ctx.subheader("The following commit message would be generated:");
304 println!();
305
306 if messages.len() == 1 {
307 println!("{}", messages[0].green());
308 } else {
309 ctx.subheader("Multiple variations available:");
310 for (i, msg) in messages.iter().enumerate() {
311 println!("\n{}. {}", i + 1, format!("Option {}", i + 1).cyan().bold());
312 println!("{}", msg.green());
313 }
314 }
315
316 ctx.divider(None);
317 ctx.subheader("No commit was made. Remove --dry-run to commit.");
318 Ok(())
319}
320
321fn display_commit_messages(messages: &[String], ctx: &ExecContext) {
323 if messages.len() == 1 {
324 ctx.header("Generated Commit Message");
325 ctx.divider(None);
326 println!("{}", messages[0]);
327 ctx.divider(None);
328 } else {
329 ctx.header("Generated Commit Message Variations");
330 ctx.divider(None);
331 for (i, msg) in messages.iter().enumerate() {
332 println!("{}. {}", i + 1, msg);
333 }
334 ctx.divider(None);
335 }
336}
337
338fn push_after_commit(config: &Config, ctx: &ExecContext) -> Result<()> {
340 ctx.subheader("Pushing to remote...");
341
342 let current_branch = git::get_current_branch()?;
343 let remote = config
344 .remote
345 .as_deref()
346 .unwrap_or("origin");
347
348 match git::git_push(remote, ¤t_branch) {
349 Ok(_) => {
350 ctx.success(&format!("Pushed '{}' to '{}'", current_branch, remote));
351 }
352 Err(e) => {
353 ctx.warning(&format!("Push failed: {}. Try running 'git push' manually.", e));
354 }
355 }
356
357 Ok(())
358}
359
360async fn handle_commit_action(
362 options: &GlobalOptions,
363 config: &Config,
364 messages: &[String],
365 final_message: &mut str,
366 ctx: &ExecContext,
367) -> Result<()> {
368 let action = if options.skip_confirmation {
369 CommitAction::Commit
370 } else if options.edit {
371 CommitAction::EditExternal
373 } else if messages.len() > 1 {
374 select_commit_action_with_variants(messages.len())?
375 } else {
376 select_commit_action()?
377 };
378
379 match action {
380 CommitAction::Commit => {
381 perform_commit(final_message)?;
382 run_post_commit_hooks(config, final_message).await?;
383 ctx.success("Changes committed successfully!");
384
385 if config.gitpush.unwrap_or(false) {
387 push_after_commit(config, ctx)?;
388 }
389 }
390 CommitAction::Edit => {
391 let edited_message = edit_commit_message(final_message)?;
392 perform_commit(&edited_message)?;
393 run_post_commit_hooks(config, &edited_message).await?;
394 ctx.success("Changes committed successfully!");
395
396 if config.gitpush.unwrap_or(false) {
398 push_after_commit(config, ctx)?;
399 }
400 }
401 CommitAction::EditExternal => {
402 let edited_message = edit_in_external_editor(final_message)?;
404 if edited_message.trim().is_empty() {
405 ctx.warning("Commit cancelled - empty message.");
406 return Ok(());
407 }
408 perform_commit(&edited_message)?;
409 run_post_commit_hooks(config, &edited_message).await?;
410 ctx.success("Changes committed successfully!");
411
412 if config.gitpush.unwrap_or(false) {
414 push_after_commit(config, ctx)?;
415 }
416 }
417 CommitAction::Select { index } => {
418 let selected_message = messages[index].clone();
419 let final_msg = if !options.no_pre_hooks {
420 run_pre_commit_hooks(config, &selected_message)?
421 } else {
422 selected_message
423 };
424 perform_commit(&final_msg)?;
425 run_post_commit_hooks(config, &final_msg).await?;
426 ctx.success("Changes committed successfully!");
427
428 if config.gitpush.unwrap_or(false) {
430 push_after_commit(config, ctx)?;
431 }
432 }
433 CommitAction::Cancel => {
434 ctx.warning("Commit cancelled.");
435 }
436 CommitAction::Regenerate => {
437 Box::pin(execute(options.clone())).await?;
439 }
440 }
441
442 Ok(())
443}
444
445fn select_files_to_stage(files: &[String]) -> Result<Vec<String>> {
446 let theme = ColorfulTheme::default();
447 let selections = MultiSelect::with_theme(&theme)
448 .with_prompt("Select files to stage")
449 .items(files)
450 .interact()?;
451
452 Ok(selections.into_iter().map(|i| files[i].clone()).collect())
453}
454
455enum CommitAction {
456 Commit,
457 Edit,
458 EditExternal, Cancel,
460 Regenerate,
461 Select { index: usize },
462}
463
464fn select_commit_action() -> Result<CommitAction> {
465 let choices = vec!["Commit", "Edit message", "Cancel", "Regenerate"];
466 let selection = Select::with_theme(&ColorfulTheme::default())
467 .with_prompt("What would you like to do?")
468 .items(&choices)
469 .default(0)
470 .interact()?;
471
472 Ok(match selection {
473 0 => CommitAction::Commit,
474 1 => CommitAction::Edit,
475 2 => CommitAction::Cancel,
476 3 => CommitAction::Regenerate,
477 _ => unreachable!(),
478 })
479}
480
481fn select_commit_action_with_variants(num_variants: usize) -> Result<CommitAction> {
482 let mut choices: Vec<String> = (1..=num_variants)
483 .map(|i| format!("Use option {}", i))
484 .collect();
485 choices.extend(vec![
486 "Edit message".to_string(),
487 "Cancel".to_string(),
488 "Regenerate".to_string(),
489 ]);
490
491 let selection = Select::with_theme(&ColorfulTheme::default())
492 .with_prompt("What would you like to do?")
493 .items(&choices)
494 .default(0)
495 .interact()?;
496
497 Ok(if selection < num_variants {
498 CommitAction::Select { index: selection }
499 } else {
500 match selection - num_variants {
501 0 => CommitAction::Edit,
502 1 => CommitAction::Cancel,
503 2 => CommitAction::Regenerate,
504 _ => unreachable!(),
505 }
506 })
507}
508
509fn select_message_variant(messages: &[String]) -> Result<usize> {
510 let selection = Select::with_theme(&ColorfulTheme::default())
511 .with_prompt("Select a commit message")
512 .items(messages)
513 .default(0)
514 .interact()?;
515
516 Ok(selection)
517}
518
519fn edit_commit_message(original: &str) -> Result<String> {
520 Input::with_theme(&ColorfulTheme::default())
521 .with_prompt("Edit commit message")
522 .with_initial_text(original)
523 .interact_text()
524 .context("Failed to read edited commit message")
525}
526
527fn edit_in_external_editor(original: &str) -> Result<String> {
529 use std::env;
530 use tempfile::NamedTempFile;
531 use std::io::Write;
532 use std::process::Command;
533
534 let editor = env::var("EDITOR")
536 .or_else(|_| env::var("VISUAL"))
537 .unwrap_or_else(|_| {
538 if cfg!(target_os = "windows") {
540 "notepad".to_string()
541 } else {
542 "vi".to_string()
543 }
544 });
545
546 let mut temp_file = NamedTempFile::with_suffix(".txt")
548 .context("Failed to create temporary file for editing")?;
549
550 temp_file
552 .write_all(original.as_bytes())
553 .context("Failed to write to temporary file")?;
554 temp_file.flush().context("Failed to flush temporary file")?;
555
556 let temp_path = temp_file.path().to_path_buf();
557
558 let _temp_file = temp_file.into_temp_path();
560
561 let status = Command::new(&editor)
563 .arg(&temp_path)
564 .status()
565 .with_context(|| format!("Failed to open editor '{}'. Make sure $EDITOR is set correctly.", editor))?;
566
567 if !status.success() {
568 anyhow::bail!("Editor exited with error status");
569 }
570
571 let edited = std::fs::read_to_string(&temp_path)
573 .context("Failed to read edited commit message from temporary file")?;
574
575 Ok(edited)
576}
577
578fn perform_commit(message: &str) -> Result<()> {
579 let output = Command::new("git")
580 .args(["commit", "-m", message])
581 .output()
582 .context("Failed to execute git commit")?;
583
584 if !output.status.success() {
585 let stderr = String::from_utf8_lossy(&output.stderr);
586 anyhow::bail!("Git commit failed: {}", stderr);
587 }
588
589 Ok(())
590}
591
592async fn run_post_commit_hooks(config: &Config, message: &str) -> Result<()> {
593 if let Some(hooks) = config.post_commit_hook.clone() {
594 let envs = vec![
595 ("RCO_REPO_ROOT", git::get_repo_root()?.to_string()),
596 ("RCO_COMMIT_MESSAGE", message.to_string()),
597 (
598 "RCO_PROVIDER",
599 config.ai_provider.clone().unwrap_or_default(),
600 ),
601 ("RCO_MODEL", config.model.clone().unwrap_or_default()),
602 ];
603 run_hooks(HookOptions {
604 name: "post-commit",
605 commands: hooks,
606 strict: config.hook_strict.unwrap_or(true),
607 timeout: std::time::Duration::from_millis(config.hook_timeout_ms.unwrap_or(30000)),
608 envs,
609 })?;
610 }
611 Ok(())
612}
613
614fn run_pre_commit_hooks(config: &Config, message: &str) -> Result<String> {
616 if let Some(hooks) = config.pre_commit_hook.clone() {
617 let commit_file = write_temp_commit_file(message)?;
618 let envs = vec![
619 ("RCO_REPO_ROOT", git::get_repo_root()?.to_string()),
620 ("RCO_COMMIT_MESSAGE", message.to_string()),
621 ("RCO_COMMIT_FILE", commit_file.to_string_lossy().to_string()),
622 (
623 "RCO_PROVIDER",
624 config.ai_provider.clone().unwrap_or_default(),
625 ),
626 ("RCO_MODEL", config.model.clone().unwrap_or_default()),
627 ];
628 run_hooks(HookOptions {
629 name: "pre-commit",
630 commands: hooks,
631 strict: config.hook_strict.unwrap_or(true),
632 timeout: std::time::Duration::from_millis(config.hook_timeout_ms.unwrap_or(30000)),
633 envs,
634 })?;
635 if let Ok(updated) = std::fs::read_to_string(&commit_file) {
637 if !updated.trim().is_empty() {
638 return Ok(updated);
639 }
640 }
641 }
642 Ok(message.to_string())
643}
644
645async fn generate_commit_messages(
646 config: &Config,
647 diff: &str,
648 context: Option<&str>,
649 full_gitmoji: bool,
650 count: u8,
651 strip_thinking: bool,
652 ctx: &ExecContext,
653) -> Result<Vec<String>> {
654 let pb = progress::spinner(&format!(
655 "Generating {} commit message{}...",
656 count,
657 if count > 1 { "s" } else { "" }
658 ));
659
660 let provider: Box<dyn providers::AIProvider> =
662 if let Some(account) = config.get_active_account()? {
663 tracing::info!("Using account: {}", account.alias);
664 ctx.key_value("Using account", &account.alias);
665 providers::create_provider_for_account(&account, config)?
666 } else {
667 providers::create_provider(config)?
668 };
669
670 let mut messages = provider
671 .generate_commit_messages(diff, context, full_gitmoji, config, count)
672 .await?;
673
674 if strip_thinking {
676 for message in &mut messages {
677 *message = utils::strip_thinking(message);
678 }
679 }
680
681 pb.finish_with_message("Commit message(s) generated!");
682 Ok(messages)
683}
684
685fn load_rcoignore() -> Result<Vec<String>> {
687 let repo_root = git::get_repo_root()?;
688 let rcoignore_path = Path::new(&repo_root).join(".rcoignore");
689
690 if !rcoignore_path.exists() {
691 return Ok(vec![]);
692 }
693
694 let content = std::fs::read_to_string(&rcoignore_path)?;
695 Ok(content
696 .lines()
697 .map(|s| s.trim().to_string())
698 .filter(|s| !s.is_empty() && !s.starts_with('#'))
699 .collect())
700}
701
702fn filter_diff_by_rcoignore(diff: &str) -> Result<String> {
704 let patterns = load_rcoignore();
705 let patterns = match patterns {
706 Ok(p) => p,
707 Err(e) => {
708 eprintln!("Warning: Failed to read .rcoignore: {}", e);
709 return Ok(diff.to_string());
710 }
711 };
712
713 if patterns.is_empty() {
714 return Ok(diff.to_string());
715 }
716
717 let mut filtered = String::with_capacity(diff.len().min(1024));
719 let mut include_current_file = true;
720
721 for line in diff.lines() {
722 if line.starts_with("+++ b/") || line.starts_with("--- a/") {
723 let file_path = line
724 .strip_prefix("+++ b/")
725 .unwrap_or_else(|| line.strip_prefix("--- a/").unwrap_or(&line[6..]));
726
727 include_current_file = !patterns.iter().any(|pattern| {
728 if pattern.starts_with('/') {
729 file_path.trim_start_matches('/') == pattern.trim_start_matches('/')
731 } else {
732 file_path.contains(pattern)
734 }
735 });
736 }
737
738 if include_current_file {
739 filtered.push_str(line);
740 filtered.push('\n');
741 }
742 }
743
744 Ok(filtered)
745}
746
747fn chunk_diff(diff: &str, max_tokens: usize) -> Result<String> {
749 let effective_max = max_tokens.saturating_sub(PROMPT_OVERHEAD_TOKENS);
751 let chunked = utils::chunk_diff(diff, effective_max);
752
753 if chunked.contains("---CHUNK") {
755 tracing::info!("Diff was chunked for token limit");
756 }
757
758 Ok(chunked)
759}
760
761fn copy_to_clipboard(text: &str) -> Result<()> {
763 #[cfg(target_os = "macos")]
764 {
765 use std::io::Write;
766 use std::process::{Command, Stdio};
767
768 let mut process = Command::new("pbcopy")
770 .stdin(Stdio::piped())
771 .spawn()
772 .context("Failed to spawn pbcopy process")?;
773
774 {
776 let stdin = process
777 .stdin
778 .as_mut()
779 .context("pbcopy stdin not available")?;
780 stdin
781 .write_all(text.as_bytes())
782 .context("Failed to write to clipboard")?;
783 }
784
785 let status = process
786 .wait()
787 .context("Failed to wait for pbcopy process")?;
788
789 if !status.success() {
790 anyhow::bail!("pbcopy exited with error: {:?}", status);
791 }
792 }
793
794 #[cfg(target_os = "linux")]
795 {
796 use std::io::Write;
797 use std::process::{Command, Stdio};
798
799 let use_xclip = !Command::new("which")
801 .arg("xclip")
802 .output()?
803 .stdout
804 .is_empty();
805
806 let (cmd_name, args) = if use_xclip {
807 ("xclip", vec!["-selection", "clipboard"])
808 } else {
809 ("xsel", vec!["--clipboard", "--input"])
810 };
811
812 let mut process = Command::new(cmd_name)
813 .args(&args)
814 .stdin(Stdio::piped())
815 .spawn()
816 .context(format!("Failed to spawn {} process", cmd_name))?;
817
818 {
819 let stdin = process
820 .stdin
821 .as_mut()
822 .context(format!("{} stdin not available", cmd_name))?;
823 stdin
824 .write_all(text.as_bytes())
825 .context("Failed to write to clipboard")?;
826 }
827
828 let status = process
829 .wait()
830 .context(format!("Failed to wait for {} process", cmd_name))?;
831
832 if !status.success() {
833 anyhow::bail!("{} exited with error: {:?}", cmd_name, status);
834 }
835 }
836
837 #[cfg(target_os = "windows")]
838 {
839 let mut ctx = arboard::Clipboard::new()
840 .map_err(|e| anyhow::anyhow!("Failed to access clipboard: {}", e))?;
841 ctx.set_text(text.to_string())
842 .map_err(|e| anyhow::anyhow!("Failed to set clipboard contents: {}", e))?;
843 }
844
845 Ok(())
846}