Skip to main content

rusty_commit/commands/
commit.rs

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
16/// Tokens reserved for prompt overhead when chunking diffs.
17/// This accounts for system prompts, user instructions, and response tokens
18/// that are sent alongside the diff content.
19const PROMPT_OVERHEAD_TOKENS: usize = 500;
20
21/// Execution context for commit message output.
22struct ExecContext;
23
24impl ExecContext {
25    fn new(_options: &GlobalOptions) -> Self {
26        Self
27    }
28
29    /// Print a success message.
30    fn success(&self, message: &str) {
31        println!("{} {}", "✓".green(), message);
32    }
33
34    /// Print a warning message.
35    fn warning(&self, message: &str) {
36        eprintln!("{} {}", "!".yellow().bold(), message);
37    }
38
39    /// Print an error message.
40    fn error(&self, message: &str) {
41        eprintln!("{} {}", "✗".red(), message);
42    }
43
44    /// Print a header.
45    fn header(&self, text: &str) {
46        println!("\n{}", text.bold());
47    }
48
49    /// Print a subheader.
50    fn subheader(&self, text: &str) {
51        println!("{}", text.dimmed());
52    }
53
54    /// Print a divider.
55    fn divider(&self, length: Option<usize>) {
56        let len = length.unwrap_or(50);
57        println!("{}", Styling::divider(len));
58    }
59
60    /// Print a key-value pair.
61    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    // Ensure we're in a git repository
70    git::assert_git_repo()?;
71
72    // Load and validate configuration
73    let config = load_and_validate_config()?;
74
75    // Determine effective generate count (CLI > config > default), clamped to 1-5
76    let generate_count = options
77        .generate_count
78        .max(config.generate_count.unwrap_or(1))
79        .clamp(1, 5);
80
81    // Prepare the diff for processing
82    let (final_diff, token_count) = prepare_diff(&config, &ctx)?;
83
84    // If --show-prompt flag is set, just show the prompt and exit
85    if options.show_prompt {
86        display_prompt(&config, &final_diff, options.context.as_deref(), &ctx);
87        return Ok(());
88    }
89
90    // Run pre-generation hooks
91    if !options.no_pre_hooks {
92        run_pre_gen_hooks(&config, token_count, options.context.as_deref())?;
93    }
94
95    // Generate commit message(s)
96    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    // Handle clipboard mode
112    if options.clipboard {
113        return handle_clipboard_mode(&messages, &ctx);
114    }
115
116    // Handle print mode
117    if options.print_message {
118        print!("{}", messages[0]);
119        return Ok(());
120    }
121
122    // Run pre-commit hooks on first message
123    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 messages and handle commit action
129    display_commit_messages(&messages, &ctx);
130    handle_commit_action(&options, &config, &messages, &mut final_message, &ctx).await
131}
132
133/// Load configuration and apply commitlint rules
134fn 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
141/// Prepare the diff for processing: get staged changes, apply filters, chunk if needed
142fn prepare_diff(config: &Config, ctx: &ExecContext) -> Result<(String, usize)> {
143    // Check for staged files or changes
144    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    // If no staged files, ask user which files to stage
158    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    // Stage selected files
165    if !files_to_stage.is_empty() {
166        git::stage_files(&files_to_stage)?;
167    }
168
169    // Get the diff of staged changes
170    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    // Apply .rcoignore if it exists
177    let diff = filter_diff_by_rcoignore(&diff)?;
178
179    // Check if diff became empty after filtering
180    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    // Check if diff is too large - implement chunking if needed
186    let max_tokens = config.tokens_max_input.unwrap_or(4096);
187    let token_count = utils::token::estimate_tokens(&diff)?;
188
189    // If diff is too large, chunk it
190    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    // Check if diff is empty after chunking
201    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
210/// Display the prompt that would be sent to AI
211fn 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
219/// Run pre-generation hooks
220fn 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
247/// Handle clipboard mode - copy message to clipboard and exit
248fn 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
259/// Display the generated commit message(s)
260fn 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
276/// Handle the commit action (commit, edit, select, cancel, regenerate)
277async 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            // Recursive call to regenerate
320            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
444/// Run pre-commit hooks on a commit message, returning the possibly modified message
445fn 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        // Read back possibly modified commit file
466        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    // Try to use an active account first
491    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    // Strip thinking tags if requested
505    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
515/// Load and parse .rcoignore file
516fn 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
532/// Filter diff to exclude files matching .rcoignore patterns
533fn 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    // Pre-allocate with reasonable capacity estimate
548    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                    // Exact match from root
560                    file_path.trim_start_matches('/') == pattern.trim_start_matches('/')
561                } else {
562                    // Match anywhere in path
563                    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
577/// Chunk a large diff into smaller pieces that fit within token limit
578fn chunk_diff(diff: &str, max_tokens: usize) -> Result<String> {
579    // Use the enhanced multi-level chunking from utils
580    let effective_max = max_tokens.saturating_sub(PROMPT_OVERHEAD_TOKENS);
581    let chunked = utils::chunk_diff(diff, effective_max);
582
583    // Log if chunking occurred
584    if chunked.contains("---CHUNK") {
585        tracing::info!("Diff was chunked for token limit");
586    }
587
588    Ok(chunked)
589}
590
591/// Copy text to clipboard with proper error handling
592fn 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        // Use pbcopy with properly piped stdin
599        let mut process = Command::new("pbcopy")
600            .stdin(Stdio::piped())
601            .spawn()
602            .context("Failed to spawn pbcopy process")?;
603
604        // Write to stdin, handling the Result properly
605        {
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        // Check if xclip is available, otherwise try xsel as fallback
630        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}