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()?;
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 let mut final_message = messages[0].clone();
124 if !options.no_pre_hooks {
125 final_message = run_pre_commit_hooks(&config, &final_message)?;
126 }
127
128 display_commit_messages(&messages, &ctx);
130 handle_commit_action(&options, &config, &messages, &mut final_message, &ctx).await
131}
132
133fn load_and_validate_config() -> Result<Config> {
135 let mut config = Config::load()?;
136 config.load_with_commitlint()?;
137 config.apply_commitlint_rules()?;
138 Ok(config)
139}
140
141fn prepare_diff(config: &Config, ctx: &ExecContext) -> Result<(String, usize)> {
143 let staged_files = git::get_staged_files()?;
145 let changed_files = if staged_files.is_empty() {
146 git::get_changed_files()?
147 } else {
148 staged_files
149 };
150
151 if changed_files.is_empty() {
152 ctx.error("No changes to commit");
153 ctx.subheader("Stage some changes with 'git add' or use 'git add -A' to stage all changes");
154 anyhow::bail!("No changes to commit");
155 }
156
157 let files_to_stage = if git::get_staged_files()?.is_empty() {
159 select_files_to_stage(&changed_files)?
160 } else {
161 vec![]
162 };
163
164 if !files_to_stage.is_empty() {
166 git::stage_files(&files_to_stage)?;
167 }
168
169 let diff = git::get_staged_diff()?;
171 if diff.is_empty() {
172 ctx.error("No staged changes to commit");
173 anyhow::bail!("No staged changes to commit");
174 }
175
176 let diff = filter_diff_by_rcoignore(&diff)?;
178
179 if diff.trim().is_empty() {
181 ctx.error("No changes to commit after applying .rcoignore filters");
182 anyhow::bail!("No changes to commit after applying .rcoignore filters");
183 }
184
185 let max_tokens = config.tokens_max_input.unwrap_or(4096);
187 let token_count = utils::token::estimate_tokens(&diff)?;
188
189 let final_diff = if token_count > max_tokens {
191 ctx.warning(&format!(
192 "The diff is too large ({} tokens). Splitting into chunks...",
193 token_count
194 ));
195 chunk_diff(&diff, max_tokens)?
196 } else {
197 diff
198 };
199
200 if final_diff.trim().is_empty() {
202 anyhow::bail!(
203 "Diff is empty after processing. This may indicate all files were excluded by .rcoignore."
204 );
205 }
206
207 Ok((final_diff, token_count))
208}
209
210fn display_prompt(config: &Config, diff: &str, context: Option<&str>, ctx: &ExecContext) {
212 let prompt = config.get_effective_prompt(diff, context, false);
213 ctx.header("Prompt that would be sent to AI");
214 ctx.divider(None);
215 println!("{}", prompt);
216 ctx.divider(None);
217}
218
219fn run_pre_gen_hooks(config: &Config, token_count: usize, context: Option<&str>) -> Result<()> {
221 if let Some(hooks) = config.pre_gen_hook.clone() {
222 let envs = vec![
223 ("RCO_REPO_ROOT", git::get_repo_root()?.to_string()),
224 (
225 "RCO_MAX_TOKENS",
226 (config.tokens_max_input.unwrap_or(4096)).to_string(),
227 ),
228 ("RCO_DIFF_TOKENS", token_count.to_string()),
229 ("RCO_CONTEXT", context.unwrap_or_default().to_string()),
230 (
231 "RCO_PROVIDER",
232 config.ai_provider.clone().unwrap_or_default(),
233 ),
234 ("RCO_MODEL", config.model.clone().unwrap_or_default()),
235 ];
236 run_hooks(HookOptions {
237 name: "pre-gen",
238 commands: hooks,
239 strict: config.hook_strict.unwrap_or(true),
240 timeout: std::time::Duration::from_millis(config.hook_timeout_ms.unwrap_or(30000)),
241 envs,
242 })?;
243 }
244 Ok(())
245}
246
247fn handle_clipboard_mode(messages: &[String], ctx: &ExecContext) -> Result<()> {
249 let selected = if messages.len() == 1 {
250 0
251 } else {
252 select_message_variant(messages)?
253 };
254 copy_to_clipboard(&messages[selected])?;
255 ctx.success("Commit message copied to clipboard!");
256 Ok(())
257}
258
259fn display_commit_messages(messages: &[String], ctx: &ExecContext) {
261 if messages.len() == 1 {
262 ctx.header("Generated Commit Message");
263 ctx.divider(None);
264 println!("{}", messages[0]);
265 ctx.divider(None);
266 } else {
267 ctx.header("Generated Commit Message Variations");
268 ctx.divider(None);
269 for (i, msg) in messages.iter().enumerate() {
270 println!("{}. {}", i + 1, msg);
271 }
272 ctx.divider(None);
273 }
274}
275
276async fn handle_commit_action(
278 options: &GlobalOptions,
279 config: &Config,
280 messages: &[String],
281 final_message: &mut str,
282 ctx: &ExecContext,
283) -> Result<()> {
284 let action = if options.skip_confirmation {
285 CommitAction::Commit
286 } else if messages.len() > 1 {
287 select_commit_action_with_variants(messages.len())?
288 } else {
289 select_commit_action()?
290 };
291
292 match action {
293 CommitAction::Commit => {
294 perform_commit(final_message)?;
295 run_post_commit_hooks(config, final_message).await?;
296 ctx.success("Changes committed successfully!");
297 }
298 CommitAction::Edit => {
299 let edited_message = edit_commit_message(final_message)?;
300 perform_commit(&edited_message)?;
301 run_post_commit_hooks(config, &edited_message).await?;
302 ctx.success("Changes committed successfully!");
303 }
304 CommitAction::Select { index } => {
305 let selected_message = messages[index].clone();
306 let final_msg = if !options.no_pre_hooks {
307 run_pre_commit_hooks(config, &selected_message)?
308 } else {
309 selected_message
310 };
311 perform_commit(&final_msg)?;
312 run_post_commit_hooks(config, &final_msg).await?;
313 ctx.success("Changes committed successfully!");
314 }
315 CommitAction::Cancel => {
316 ctx.warning("Commit cancelled.");
317 }
318 CommitAction::Regenerate => {
319 Box::pin(execute(options.clone())).await?;
321 }
322 }
323
324 Ok(())
325}
326
327fn select_files_to_stage(files: &[String]) -> Result<Vec<String>> {
328 let theme = ColorfulTheme::default();
329 let selections = MultiSelect::with_theme(&theme)
330 .with_prompt("Select files to stage")
331 .items(files)
332 .interact()?;
333
334 Ok(selections.into_iter().map(|i| files[i].clone()).collect())
335}
336
337enum CommitAction {
338 Commit,
339 Edit,
340 Cancel,
341 Regenerate,
342 Select { index: usize },
343}
344
345fn select_commit_action() -> Result<CommitAction> {
346 let choices = vec!["Commit", "Edit message", "Cancel", "Regenerate"];
347 let selection = Select::with_theme(&ColorfulTheme::default())
348 .with_prompt("What would you like to do?")
349 .items(&choices)
350 .default(0)
351 .interact()?;
352
353 Ok(match selection {
354 0 => CommitAction::Commit,
355 1 => CommitAction::Edit,
356 2 => CommitAction::Cancel,
357 3 => CommitAction::Regenerate,
358 _ => unreachable!(),
359 })
360}
361
362fn select_commit_action_with_variants(num_variants: usize) -> Result<CommitAction> {
363 let mut choices: Vec<String> = (1..=num_variants)
364 .map(|i| format!("Use option {}", i))
365 .collect();
366 choices.extend(vec![
367 "Edit message".to_string(),
368 "Cancel".to_string(),
369 "Regenerate".to_string(),
370 ]);
371
372 let selection = Select::with_theme(&ColorfulTheme::default())
373 .with_prompt("What would you like to do?")
374 .items(&choices)
375 .default(0)
376 .interact()?;
377
378 Ok(if selection < num_variants {
379 CommitAction::Select { index: selection }
380 } else {
381 match selection - num_variants {
382 0 => CommitAction::Edit,
383 1 => CommitAction::Cancel,
384 2 => CommitAction::Regenerate,
385 _ => unreachable!(),
386 }
387 })
388}
389
390fn select_message_variant(messages: &[String]) -> Result<usize> {
391 let selection = Select::with_theme(&ColorfulTheme::default())
392 .with_prompt("Select a commit message")
393 .items(messages)
394 .default(0)
395 .interact()?;
396
397 Ok(selection)
398}
399
400fn edit_commit_message(original: &str) -> Result<String> {
401 Input::with_theme(&ColorfulTheme::default())
402 .with_prompt("Edit commit message")
403 .with_initial_text(original)
404 .interact_text()
405 .context("Failed to read edited commit message")
406}
407
408fn perform_commit(message: &str) -> Result<()> {
409 let output = Command::new("git")
410 .args(["commit", "-m", message])
411 .output()
412 .context("Failed to execute git commit")?;
413
414 if !output.status.success() {
415 let stderr = String::from_utf8_lossy(&output.stderr);
416 anyhow::bail!("Git commit failed: {}", stderr);
417 }
418
419 Ok(())
420}
421
422async fn run_post_commit_hooks(config: &Config, message: &str) -> Result<()> {
423 if let Some(hooks) = config.post_commit_hook.clone() {
424 let envs = vec![
425 ("RCO_REPO_ROOT", git::get_repo_root()?.to_string()),
426 ("RCO_COMMIT_MESSAGE", message.to_string()),
427 (
428 "RCO_PROVIDER",
429 config.ai_provider.clone().unwrap_or_default(),
430 ),
431 ("RCO_MODEL", config.model.clone().unwrap_or_default()),
432 ];
433 run_hooks(HookOptions {
434 name: "post-commit",
435 commands: hooks,
436 strict: config.hook_strict.unwrap_or(true),
437 timeout: std::time::Duration::from_millis(config.hook_timeout_ms.unwrap_or(30000)),
438 envs,
439 })?;
440 }
441 Ok(())
442}
443
444fn run_pre_commit_hooks(config: &Config, message: &str) -> Result<String> {
446 if let Some(hooks) = config.pre_commit_hook.clone() {
447 let commit_file = write_temp_commit_file(message)?;
448 let envs = vec![
449 ("RCO_REPO_ROOT", git::get_repo_root()?.to_string()),
450 ("RCO_COMMIT_MESSAGE", message.to_string()),
451 ("RCO_COMMIT_FILE", commit_file.to_string_lossy().to_string()),
452 (
453 "RCO_PROVIDER",
454 config.ai_provider.clone().unwrap_or_default(),
455 ),
456 ("RCO_MODEL", config.model.clone().unwrap_or_default()),
457 ];
458 run_hooks(HookOptions {
459 name: "pre-commit",
460 commands: hooks,
461 strict: config.hook_strict.unwrap_or(true),
462 timeout: std::time::Duration::from_millis(config.hook_timeout_ms.unwrap_or(30000)),
463 envs,
464 })?;
465 if let Ok(updated) = std::fs::read_to_string(&commit_file) {
467 if !updated.trim().is_empty() {
468 return Ok(updated);
469 }
470 }
471 }
472 Ok(message.to_string())
473}
474
475async fn generate_commit_messages(
476 config: &Config,
477 diff: &str,
478 context: Option<&str>,
479 full_gitmoji: bool,
480 count: u8,
481 strip_thinking: bool,
482 ctx: &ExecContext,
483) -> Result<Vec<String>> {
484 let pb = progress::spinner(&format!(
485 "Generating {} commit message{}...",
486 count,
487 if count > 1 { "s" } else { "" }
488 ));
489
490 let provider: Box<dyn providers::AIProvider> =
492 if let Some(account) = config.get_active_account()? {
493 tracing::info!("Using account: {}", account.alias);
494 ctx.key_value("Using account", &account.alias);
495 providers::create_provider_for_account(&account, config)?
496 } else {
497 providers::create_provider(config)?
498 };
499
500 let mut messages = provider
501 .generate_commit_messages(diff, context, full_gitmoji, config, count)
502 .await?;
503
504 if strip_thinking {
506 for message in &mut messages {
507 *message = utils::strip_thinking(message);
508 }
509 }
510
511 pb.finish_with_message("Commit message(s) generated!");
512 Ok(messages)
513}
514
515fn load_rcoignore() -> Result<Vec<String>> {
517 let repo_root = git::get_repo_root()?;
518 let rcoignore_path = Path::new(&repo_root).join(".rcoignore");
519
520 if !rcoignore_path.exists() {
521 return Ok(vec![]);
522 }
523
524 let content = std::fs::read_to_string(&rcoignore_path)?;
525 Ok(content
526 .lines()
527 .map(|s| s.trim().to_string())
528 .filter(|s| !s.is_empty() && !s.starts_with('#'))
529 .collect())
530}
531
532fn filter_diff_by_rcoignore(diff: &str) -> Result<String> {
534 let patterns = load_rcoignore();
535 let patterns = match patterns {
536 Ok(p) => p,
537 Err(e) => {
538 eprintln!("Warning: Failed to read .rcoignore: {}", e);
539 return Ok(diff.to_string());
540 }
541 };
542
543 if patterns.is_empty() {
544 return Ok(diff.to_string());
545 }
546
547 let mut filtered = String::with_capacity(diff.len().min(1024));
549 let mut include_current_file = true;
550
551 for line in diff.lines() {
552 if line.starts_with("+++ b/") || line.starts_with("--- a/") {
553 let file_path = line
554 .strip_prefix("+++ b/")
555 .unwrap_or_else(|| line.strip_prefix("--- a/").unwrap_or(&line[6..]));
556
557 include_current_file = !patterns.iter().any(|pattern| {
558 if pattern.starts_with('/') {
559 file_path.trim_start_matches('/') == pattern.trim_start_matches('/')
561 } else {
562 file_path.contains(pattern)
564 }
565 });
566 }
567
568 if include_current_file {
569 filtered.push_str(line);
570 filtered.push('\n');
571 }
572 }
573
574 Ok(filtered)
575}
576
577fn chunk_diff(diff: &str, max_tokens: usize) -> Result<String> {
579 let effective_max = max_tokens.saturating_sub(PROMPT_OVERHEAD_TOKENS);
581 let chunked = utils::chunk_diff(diff, effective_max);
582
583 if chunked.contains("---CHUNK") {
585 tracing::info!("Diff was chunked for token limit");
586 }
587
588 Ok(chunked)
589}
590
591fn copy_to_clipboard(text: &str) -> Result<()> {
593 #[cfg(target_os = "macos")]
594 {
595 use std::io::Write;
596 use std::process::{Command, Stdio};
597
598 let mut process = Command::new("pbcopy")
600 .stdin(Stdio::piped())
601 .spawn()
602 .context("Failed to spawn pbcopy process")?;
603
604 {
606 let stdin = process
607 .stdin
608 .as_mut()
609 .context("pbcopy stdin not available")?;
610 stdin
611 .write_all(text.as_bytes())
612 .context("Failed to write to clipboard")?;
613 }
614
615 let status = process
616 .wait()
617 .context("Failed to wait for pbcopy process")?;
618
619 if !status.success() {
620 anyhow::bail!("pbcopy exited with error: {:?}", status);
621 }
622 }
623
624 #[cfg(target_os = "linux")]
625 {
626 use std::io::Write;
627 use std::process::{Command, Stdio};
628
629 let use_xclip = !Command::new("which")
631 .arg("xclip")
632 .output()?
633 .stdout
634 .is_empty();
635
636 let (cmd_name, args) = if use_xclip {
637 ("xclip", vec!["-selection", "clipboard"])
638 } else {
639 ("xsel", vec!["--clipboard", "--input"])
640 };
641
642 let mut process = Command::new(cmd_name)
643 .args(&args)
644 .stdin(Stdio::piped())
645 .spawn()
646 .context(format!("Failed to spawn {} process", cmd_name))?;
647
648 {
649 let stdin = process
650 .stdin
651 .as_mut()
652 .context(format!("{} stdin not available", cmd_name))?;
653 stdin
654 .write_all(text.as_bytes())
655 .context("Failed to write to clipboard")?;
656 }
657
658 let status = process
659 .wait()
660 .context(format!("Failed to wait for {} process", cmd_name))?;
661
662 if !status.success() {
663 anyhow::bail!("{} exited with error: {:?}", cmd_name, status);
664 }
665 }
666
667 #[cfg(target_os = "windows")]
668 {
669 let mut ctx = arboard::Clipboard::new()
670 .map_err(|e| anyhow::anyhow!("Failed to access clipboard: {}", e))?;
671 ctx.set_text(text.to_string())
672 .map_err(|e| anyhow::anyhow!("Failed to set clipboard contents: {}", e))?;
673 }
674
675 Ok(())
676}