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.find(skill_name).ok_or_else(|| {
165 anyhow::anyhow!(
166 "Skill '{}' not found. Run 'rco skills list' to see available skills.",
167 skill_name
168 )
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.remote.as_deref().unwrap_or("origin");
344
345 match git::git_push(remote, ¤t_branch) {
346 Ok(_) => {
347 ctx.success(&format!("Pushed '{}' to '{}'", current_branch, remote));
348 }
349 Err(e) => {
350 ctx.warning(&format!(
351 "Push failed: {}. Try running 'git push' manually.",
352 e
353 ));
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 std::io::Write;
531 use std::process::Command;
532 use tempfile::NamedTempFile;
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
555 .flush()
556 .context("Failed to flush temporary file")?;
557
558 let temp_path = temp_file.path().to_path_buf();
559
560 let _temp_file = temp_file.into_temp_path();
562
563 let status = Command::new(&editor)
565 .arg(&temp_path)
566 .status()
567 .with_context(|| {
568 format!(
569 "Failed to open editor '{}'. Make sure $EDITOR is set correctly.",
570 editor
571 )
572 })?;
573
574 if !status.success() {
575 anyhow::bail!("Editor exited with error status");
576 }
577
578 let edited = std::fs::read_to_string(&temp_path)
580 .context("Failed to read edited commit message from temporary file")?;
581
582 Ok(edited)
583}
584
585fn perform_commit(message: &str) -> Result<()> {
586 let output = Command::new("git")
587 .args(["commit", "-m", message])
588 .output()
589 .context("Failed to execute git commit")?;
590
591 if !output.status.success() {
592 let stderr = String::from_utf8_lossy(&output.stderr);
593 anyhow::bail!("Git commit failed: {}", stderr);
594 }
595
596 Ok(())
597}
598
599async fn run_post_commit_hooks(config: &Config, message: &str) -> Result<()> {
600 if let Some(hooks) = config.post_commit_hook.clone() {
601 let envs = vec![
602 ("RCO_REPO_ROOT", git::get_repo_root()?.to_string()),
603 ("RCO_COMMIT_MESSAGE", message.to_string()),
604 (
605 "RCO_PROVIDER",
606 config.ai_provider.clone().unwrap_or_default(),
607 ),
608 ("RCO_MODEL", config.model.clone().unwrap_or_default()),
609 ];
610 run_hooks(HookOptions {
611 name: "post-commit",
612 commands: hooks,
613 strict: config.hook_strict.unwrap_or(true),
614 timeout: std::time::Duration::from_millis(config.hook_timeout_ms.unwrap_or(30000)),
615 envs,
616 })?;
617 }
618 Ok(())
619}
620
621fn run_pre_commit_hooks(config: &Config, message: &str) -> Result<String> {
623 if let Some(hooks) = config.pre_commit_hook.clone() {
624 let commit_file = write_temp_commit_file(message)?;
625 let envs = vec![
626 ("RCO_REPO_ROOT", git::get_repo_root()?.to_string()),
627 ("RCO_COMMIT_MESSAGE", message.to_string()),
628 ("RCO_COMMIT_FILE", commit_file.to_string_lossy().to_string()),
629 (
630 "RCO_PROVIDER",
631 config.ai_provider.clone().unwrap_or_default(),
632 ),
633 ("RCO_MODEL", config.model.clone().unwrap_or_default()),
634 ];
635 run_hooks(HookOptions {
636 name: "pre-commit",
637 commands: hooks,
638 strict: config.hook_strict.unwrap_or(true),
639 timeout: std::time::Duration::from_millis(config.hook_timeout_ms.unwrap_or(30000)),
640 envs,
641 })?;
642 if let Ok(updated) = std::fs::read_to_string(&commit_file) {
644 if !updated.trim().is_empty() {
645 return Ok(updated);
646 }
647 }
648 }
649 Ok(message.to_string())
650}
651
652async fn generate_commit_messages(
653 config: &Config,
654 diff: &str,
655 context: Option<&str>,
656 full_gitmoji: bool,
657 count: u8,
658 strip_thinking: bool,
659 ctx: &ExecContext,
660) -> Result<Vec<String>> {
661 let pb = progress::spinner(&format!(
662 "Generating {} commit message{}...",
663 count,
664 if count > 1 { "s" } else { "" }
665 ));
666
667 let provider: Box<dyn providers::AIProvider> =
669 if let Some(account) = config.get_active_account()? {
670 tracing::info!("Using account: {}", account.alias);
671 ctx.key_value("Using account", &account.alias);
672 providers::create_provider_for_account(&account, config)?
673 } else {
674 providers::create_provider(config)?
675 };
676
677 let mut messages = provider
678 .generate_commit_messages(diff, context, full_gitmoji, config, count)
679 .await?;
680
681 if strip_thinking {
683 for message in &mut messages {
684 *message = utils::strip_thinking(message);
685 }
686 }
687
688 pb.finish_with_message("Commit message(s) generated!");
689 Ok(messages)
690}
691
692fn load_rcoignore() -> Result<Vec<String>> {
694 let repo_root = git::get_repo_root()?;
695 let rcoignore_path = Path::new(&repo_root).join(".rcoignore");
696
697 if !rcoignore_path.exists() {
698 return Ok(vec![]);
699 }
700
701 let content = std::fs::read_to_string(&rcoignore_path)?;
702 Ok(content
703 .lines()
704 .map(|s| s.trim().to_string())
705 .filter(|s| !s.is_empty() && !s.starts_with('#'))
706 .collect())
707}
708
709fn filter_diff_by_rcoignore(diff: &str) -> Result<String> {
711 let patterns = load_rcoignore();
712 let patterns = match patterns {
713 Ok(p) => p,
714 Err(e) => {
715 eprintln!("Warning: Failed to read .rcoignore: {}", e);
716 return Ok(diff.to_string());
717 }
718 };
719
720 if patterns.is_empty() {
721 return Ok(diff.to_string());
722 }
723
724 let mut filtered = String::with_capacity(diff.len().min(1024));
726 let mut include_current_file = true;
727
728 for line in diff.lines() {
729 if line.starts_with("+++ b/") || line.starts_with("--- a/") {
730 let file_path = line
731 .strip_prefix("+++ b/")
732 .unwrap_or_else(|| line.strip_prefix("--- a/").unwrap_or(&line[6..]));
733
734 include_current_file = !patterns.iter().any(|pattern| {
735 if pattern.starts_with('/') {
736 file_path.trim_start_matches('/') == pattern.trim_start_matches('/')
738 } else {
739 file_path.contains(pattern)
741 }
742 });
743 }
744
745 if include_current_file {
746 filtered.push_str(line);
747 filtered.push('\n');
748 }
749 }
750
751 Ok(filtered)
752}
753
754fn chunk_diff(diff: &str, max_tokens: usize) -> Result<String> {
756 let effective_max = max_tokens.saturating_sub(PROMPT_OVERHEAD_TOKENS);
758 let chunked = utils::chunk_diff(diff, effective_max);
759
760 if chunked.contains("---CHUNK") {
762 tracing::info!("Diff was chunked for token limit");
763 }
764
765 Ok(chunked)
766}
767
768fn copy_to_clipboard(text: &str) -> Result<()> {
770 #[cfg(target_os = "macos")]
771 {
772 use std::io::Write;
773 use std::process::{Command, Stdio};
774
775 let mut process = Command::new("pbcopy")
777 .stdin(Stdio::piped())
778 .spawn()
779 .context("Failed to spawn pbcopy process")?;
780
781 {
783 let stdin = process
784 .stdin
785 .as_mut()
786 .context("pbcopy stdin not available")?;
787 stdin
788 .write_all(text.as_bytes())
789 .context("Failed to write to clipboard")?;
790 }
791
792 let status = process
793 .wait()
794 .context("Failed to wait for pbcopy process")?;
795
796 if !status.success() {
797 anyhow::bail!("pbcopy exited with error: {:?}", status);
798 }
799 }
800
801 #[cfg(target_os = "linux")]
802 {
803 use std::io::Write;
804 use std::process::{Command, Stdio};
805
806 let use_xclip = !Command::new("which")
808 .arg("xclip")
809 .output()?
810 .stdout
811 .is_empty();
812
813 let (cmd_name, args) = if use_xclip {
814 ("xclip", vec!["-selection", "clipboard"])
815 } else {
816 ("xsel", vec!["--clipboard", "--input"])
817 };
818
819 let mut process = Command::new(cmd_name)
820 .args(&args)
821 .stdin(Stdio::piped())
822 .spawn()
823 .context(format!("Failed to spawn {} process", cmd_name))?;
824
825 {
826 let stdin = process
827 .stdin
828 .as_mut()
829 .context(format!("{} stdin not available", cmd_name))?;
830 stdin
831 .write_all(text.as_bytes())
832 .context("Failed to write to clipboard")?;
833 }
834
835 let status = process
836 .wait()
837 .context(format!("Failed to wait for {} process", cmd_name))?;
838
839 if !status.success() {
840 anyhow::bail!("{} exited with error: {:?}", cmd_name, status);
841 }
842 }
843
844 #[cfg(target_os = "windows")]
845 {
846 let mut ctx = arboard::Clipboard::new()
847 .map_err(|e| anyhow::anyhow!("Failed to access clipboard: {}", e))?;
848 ctx.set_text(text.to_string())
849 .map_err(|e| anyhow::anyhow!("Failed to set clipboard contents: {}", e))?;
850 }
851
852 Ok(())
853}