Skip to main content

gcop_rs/commands/
commit.rs

1use std::sync::Arc;
2
3use colored::Colorize;
4use serde::Serialize;
5
6use super::options::CommitOptions;
7use super::smart_truncate_diff;
8use crate::commands::commit_state_machine::{CommitState, GenerationResult, UserAction};
9use crate::commands::json::{self, JsonOutput};
10use crate::config::AppConfig;
11use crate::error::{GcopError, Result};
12use crate::git::{DiffStats, GitOperations, repository::GitRepository};
13use crate::llm::provider::base::response::process_commit_response;
14use crate::llm::{CommitContext, LLMProvider, ScopeInfo, provider::create_provider};
15use crate::ui;
16
17/// The data part of the Commit command
18#[derive(Debug, Serialize)]
19pub struct CommitData {
20    /// Final commit message produced by the command.
21    pub message: String,
22    /// Diff statistics included in JSON output.
23    pub diff_stats: DiffStatsJson,
24    /// Whether `git commit` was executed (`false` for dry-run/json-only flows).
25    pub committed: bool,
26}
27
28/// Serializable diff statistics payload used by command JSON output.
29#[derive(Debug, Serialize)]
30pub struct DiffStatsJson {
31    /// Files changed in the staged diff.
32    pub files_changed: Vec<String>,
33    /// Number of inserted lines.
34    pub insertions: usize,
35    /// Number of deleted lines.
36    pub deletions: usize,
37    /// Total changed lines (`insertions + deletions`).
38    pub total_changes: usize,
39}
40
41impl From<&DiffStats> for DiffStatsJson {
42    fn from(stats: &DiffStats) -> Self {
43        Self {
44            files_changed: stats.files_changed.clone(),
45            insertions: stats.insertions,
46            deletions: stats.deletions,
47            total_changes: stats.insertions + stats.deletions,
48        }
49    }
50}
51
52/// Execute commit command
53///
54/// # Arguments
55/// * `options` - Commit command options
56/// * `config` - application configuration
57pub async fn run(options: &CommitOptions<'_>, config: &AppConfig) -> Result<()> {
58    let repo = GitRepository::open(None)?;
59    let provider = create_provider(config, options.provider_override)?;
60
61    run_with_deps(options, config, &repo as &dyn GitOperations, &provider).await
62}
63
64/// Execute commit command (testable version, accepts trait objects)
65#[allow(dead_code)] // for testing
66async fn run_with_deps(
67    options: &CommitOptions<'_>,
68    config: &AppConfig,
69    repo: &dyn GitOperations,
70    provider: &Arc<dyn LLMProvider>,
71) -> Result<()> {
72    let colored = options.effective_colored(config);
73
74    // Merge command line parameters into one feedback (easy to use without quotes)
75    // e.g. `gcop-rs commit use Chinese` -> "use Chinese"
76    let initial_feedbacks = if options.feedback.is_empty() {
77        vec![]
78    } else {
79        vec![options.feedback.join(" ")]
80    };
81
82    // Split mode: separate flow
83    if options.split {
84        if options.amend {
85            ui::error(&rust_i18n::t!("commit.amend_split_conflict"), colored);
86            return Err(GcopError::InvalidInput(
87                "Cannot use --amend with --split".to_string(),
88            ));
89        }
90        return crate::commands::split::run_split_flow(options, config, repo, provider).await;
91    }
92
93    // Amend: require at least one existing commit
94    if options.amend && repo.is_empty()? {
95        ui::error(&rust_i18n::t!("commit.amend_no_commits"), colored);
96        return Err(GcopError::InvalidInput(
97            "Cannot amend: repository has no commits".to_string(),
98        ));
99    }
100
101    // JSON Schema: Standalone Process
102    if options.format.is_json() {
103        return handle_json_mode(options, config, repo, provider, &initial_feedbacks).await;
104    }
105
106    // Get diff based on mode (normal vs amend)
107    if !options.amend && !repo.has_staged_changes()? {
108        ui::error(&rust_i18n::t!("commit.no_staged_changes"), colored);
109        return Err(GcopError::NoStagedChanges);
110    }
111    let diff = get_diff(repo, options.amend)?;
112
113    // Get diff statistics
114    let stats = repo.get_diff_stats(&diff)?;
115
116    // Truncate overly large diffs to prevent tokens from exceeding the limit
117    let (diff, truncated) = smart_truncate_diff(&diff, config.llm.max_diff_size);
118    if truncated {
119        ui::warning(&rust_i18n::t!("diff.truncated"), colored);
120    }
121
122    // Workspace scope detection
123    let scope_info = compute_scope_info(&stats.files_changed, config);
124
125    ui::step(
126        &rust_i18n::t!("commit.step1"),
127        &rust_i18n::t!(
128            "commit.analyzed",
129            files = stats.files_changed.len(),
130            changes = stats.insertions + stats.deletions
131        ),
132        colored,
133    );
134
135    if config.commit.show_diff_preview {
136        println!("\n{}", ui::format_diff_stats(&stats, colored));
137    }
138
139    // dry_run mode: only generate without submitting
140    if options.dry_run {
141        let branch_name = repo.get_current_branch()?;
142        let custom_prompt = config.commit.custom_prompt.clone();
143        let (message, already_displayed) = generate_message(
144            provider,
145            &diff,
146            &stats,
147            config,
148            &initial_feedbacks,
149            0,
150            options.verbose,
151            &branch_name,
152            &custom_prompt,
153            &scope_info,
154        )
155        .await?;
156        if !already_displayed {
157            display_message(&message, 0, config.ui.colored);
158        }
159        return Ok(());
160    }
161
162    // Interactive mode: state machine main loop
163    let should_edit = config.commit.allow_edit && !options.no_edit;
164    let max_retries = config.commit.max_retries;
165
166    // Extract the unchanged context in the loop (branch_name, custom_prompt will not change with retry)
167    let branch_name = repo.get_current_branch()?;
168    let custom_prompt = config.commit.custom_prompt.clone();
169
170    let mut state = CommitState::Generating {
171        attempt: 0,
172        feedbacks: initial_feedbacks,
173    };
174
175    loop {
176        state = match state {
177            CommitState::Generating { attempt, feedbacks } => {
178                handle_generating(
179                    attempt,
180                    feedbacks,
181                    max_retries,
182                    colored,
183                    options,
184                    config,
185                    provider,
186                    &diff,
187                    &stats,
188                    &branch_name,
189                    &custom_prompt,
190                    &scope_info,
191                )
192                .await?
193            }
194
195            CommitState::WaitingForAction {
196                ref message,
197                attempt,
198                ref feedbacks,
199            } => handle_waiting_for_action(message, attempt, feedbacks, should_edit, colored)?,
200
201            CommitState::Accepted { ref message } => {
202                ui::step(
203                    &rust_i18n::t!("commit.step4"),
204                    &rust_i18n::t!("commit.creating"),
205                    colored,
206                );
207                if options.amend {
208                    repo.commit_amend(message)?;
209                } else {
210                    repo.commit(message)?;
211                }
212                println!();
213                if options.amend {
214                    ui::success(&rust_i18n::t!("commit.amend_success"), colored);
215                } else {
216                    ui::success(&rust_i18n::t!("commit.success"), colored);
217                }
218                if options.verbose {
219                    println!("\n{}", message);
220                }
221                return Ok(());
222            }
223
224            CommitState::Cancelled => {
225                ui::warning(&rust_i18n::t!("commit.cancelled"), colored);
226                return Err(GcopError::UserCancelled);
227            }
228        };
229    }
230}
231
232/// Full execution flow for JSON output mode.
233async fn handle_json_mode(
234    options: &CommitOptions<'_>,
235    config: &AppConfig,
236    repo: &dyn GitOperations,
237    provider: &Arc<dyn LLMProvider>,
238    initial_feedbacks: &[String],
239) -> Result<()> {
240    if !options.amend && !repo.has_staged_changes()? {
241        json::output_json_error::<CommitData>(&GcopError::NoStagedChanges)?;
242        return Err(GcopError::NoStagedChanges);
243    }
244    let diff = get_diff(repo, options.amend)?;
245    let stats = repo.get_diff_stats(&diff)?;
246    let (diff, _truncated) = smart_truncate_diff(&diff, config.llm.max_diff_size);
247    let branch_name = repo.get_current_branch()?;
248    let custom_prompt = config.commit.custom_prompt.clone();
249    let scope_info = compute_scope_info(&stats.files_changed, config);
250
251    match generate_message_no_streaming(
252        provider,
253        &diff,
254        &stats,
255        initial_feedbacks,
256        options.verbose,
257        &branch_name,
258        &custom_prompt,
259        &config.commit.convention,
260        &scope_info,
261    )
262    .await
263    {
264        Ok(message) => output_json_success(&message, &stats, false),
265        Err(e) => {
266            json::output_json_error::<CommitData>(&e)?;
267            Err(e)
268        }
269    }
270}
271
272/// Handles the `Generating` state.
273#[allow(clippy::too_many_arguments)]
274async fn handle_generating(
275    attempt: usize,
276    feedbacks: Vec<String>,
277    max_retries: usize,
278    colored: bool,
279    options: &CommitOptions<'_>,
280    config: &AppConfig,
281    provider: &Arc<dyn LLMProvider>,
282    diff: &str,
283    stats: &DiffStats,
284    branch_name: &Option<String>,
285    custom_prompt: &Option<String>,
286    scope_info: &Option<ScopeInfo>,
287) -> Result<CommitState> {
288    // Check retry limit
289    let gen_state = CommitState::Generating {
290        attempt,
291        feedbacks: feedbacks.clone(),
292    };
293
294    if gen_state.is_at_max_retries(max_retries) {
295        ui::warning(
296            &rust_i18n::t!("commit.max_retries", count = max_retries),
297            colored,
298        );
299        return gen_state.handle_generation(GenerationResult::MaxRetriesExceeded, options.yes);
300    }
301
302    // Generate message.
303    let (message, already_displayed) = generate_message(
304        provider,
305        diff,
306        stats,
307        config,
308        &feedbacks,
309        attempt,
310        options.verbose,
311        branch_name,
312        custom_prompt,
313        scope_info,
314    )
315    .await?;
316
317    // Use state-machine transition for generation result.
318    let gen_state = CommitState::Generating { attempt, feedbacks };
319    let result = GenerationResult::Success(message.clone());
320    let next_state = gen_state.handle_generation(result, options.yes)?;
321
322    // Show generated message unless it was auto-accepted or already streamed.
323    if !options.yes && !already_displayed {
324        display_message(&message, attempt, colored);
325    }
326
327    Ok(next_state)
328}
329
330/// Handles the `WaitingForAction` state.
331fn handle_waiting_for_action(
332    message: &str,
333    attempt: usize,
334    feedbacks: &[String],
335    should_edit: bool,
336    colored: bool,
337) -> Result<CommitState> {
338    ui::step(
339        &rust_i18n::t!("commit.step3"),
340        &rust_i18n::t!("commit.choose_action"),
341        colored,
342    );
343    let ui_action = ui::commit_action_menu(message, should_edit, attempt, colored)?;
344
345    // Map UI action to state-machine action and apply editor flow when needed.
346    let user_action = match ui_action {
347        ui::CommitAction::Accept => UserAction::Accept,
348
349        ui::CommitAction::Edit => {
350            ui::step(
351                &rust_i18n::t!("commit.step3"),
352                &rust_i18n::t!("commit.opening_editor"),
353                colored,
354            );
355            match ui::edit_text(message) {
356                Ok(edited) => {
357                    display_edited_message(&edited, colored);
358                    UserAction::Edit {
359                        new_message: edited,
360                    }
361                }
362                Err(GcopError::UserCancelled) => {
363                    ui::warning(&rust_i18n::t!("commit.edit_cancelled"), colored);
364                    UserAction::EditCancelled
365                }
366                Err(e) => return Err(e),
367            }
368        }
369
370        ui::CommitAction::Retry => UserAction::Retry,
371
372        ui::CommitAction::RetryWithFeedback => {
373            let new_feedback = ui::get_retry_feedback(colored)?;
374            if new_feedback.is_none() {
375                ui::warning(&rust_i18n::t!("commit.feedback.empty"), colored);
376            }
377            UserAction::RetryWithFeedback {
378                feedback: new_feedback,
379            }
380        }
381
382        ui::CommitAction::Quit => UserAction::Quit,
383    };
384
385    let waiting_state = CommitState::WaitingForAction {
386        message: message.to_string(),
387        attempt,
388        feedbacks: feedbacks.to_vec(),
389    };
390    Ok(waiting_state.handle_action(user_action))
391}
392
393/// Generates a commit message.
394///
395/// Returns `(message, already_displayed)`.
396#[allow(clippy::too_many_arguments)] // There are many parameters but reasonable
397async fn generate_message(
398    provider: &Arc<dyn LLMProvider>,
399    diff: &str,
400    stats: &DiffStats,
401    config: &AppConfig,
402    feedbacks: &[String],
403    attempt: usize,
404    verbose: bool,
405    branch_name: &Option<String>,
406    custom_prompt: &Option<String>,
407    scope_info: &Option<ScopeInfo>,
408) -> Result<(String, bool)> {
409    let context = CommitContext {
410        files_changed: stats.files_changed.clone(),
411        insertions: stats.insertions,
412        deletions: stats.deletions,
413        branch_name: branch_name.clone(),
414        custom_prompt: custom_prompt.clone(),
415        user_feedback: feedbacks.to_vec(),
416        convention: config.commit.convention.clone(),
417        scope_info: scope_info.clone(),
418    };
419
420    // Build prompt once
421    let (system, user) = crate::llm::prompt::build_commit_prompt_split(
422        diff,
423        &context,
424        context.custom_prompt.as_deref(),
425        context.convention.as_ref(),
426    );
427
428    // Show prompts in verbose mode.
429    if verbose {
430        print_verbose_prompt(&system, &user, false, true);
431    }
432
433    // Decide whether to use streaming mode.
434    let use_streaming = config.ui.streaming && provider.supports_streaming();
435    let colored = config.ui.colored;
436
437    if use_streaming {
438        // Streaming mode: print header, then stream response chunks.
439        let step_msg = if attempt == 0 {
440            rust_i18n::t!("spinner.generating_streaming")
441        } else {
442            rust_i18n::t!("spinner.regenerating_streaming")
443        };
444        ui::step(&rust_i18n::t!("commit.step2"), &step_msg, colored);
445        println!("\n{}", ui::info(&format_message_header(attempt), colored));
446
447        let stream_handle = provider.send_prompt_streaming(&system, &user).await?;
448
449        let mut output = ui::StreamingOutput::new(colored);
450        let message = output.process(stream_handle.receiver).await?;
451        let message = process_commit_response(message);
452
453        // If code fences were stripped, erase raw output and redisplay clean version
454        output.redisplay_if_cleaned(&message);
455
456        Ok((message, true)) // Already shown
457    } else {
458        // Non-streaming mode: use spinner with cancel hint and elapsed time.
459        let spinner_message = if attempt == 0 {
460            rust_i18n::t!("spinner.generating").to_string()
461        } else {
462            rust_i18n::t!("spinner.regenerating").to_string()
463        };
464        let mut spinner = ui::Spinner::new_with_cancel_hint(&spinner_message, colored);
465        spinner.start_time_display();
466
467        let message = provider.send_prompt(&system, &user, Some(&spinner)).await?;
468
469        spinner.finish_and_clear();
470        let message = process_commit_response(message);
471        Ok((message, false)) // Not shown yet
472    }
473}
474
475/// Formats the message header (pure function, easy to test).
476fn format_message_header(attempt: usize) -> String {
477    if attempt == 0 {
478        rust_i18n::t!("commit.generated").to_string()
479    } else {
480        rust_i18n::t!("commit.regenerated", attempt = attempt + 1).to_string()
481    }
482}
483
484/// Formats the edited-message header (pure function, easy to test).
485fn format_edited_header() -> String {
486    rust_i18n::t!("commit.updated").to_string()
487}
488
489/// Displays the generated message.
490fn display_message(message: &str, attempt: usize, colored: bool) {
491    let header = format_message_header(attempt);
492
493    println!("\n{}", ui::info(&header, colored));
494    if colored {
495        println!("{}", message.yellow());
496    } else {
497        println!("{}", message);
498    }
499}
500
501/// Show the edited message
502fn display_edited_message(message: &str, colored: bool) {
503    println!("\n{}", ui::info(&format_edited_header(), colored));
504    if colored {
505        println!("{}", message.yellow());
506    } else {
507        println!("{}", message);
508    }
509}
510
511/// Generate commit message (non-streaming version, for JSON output mode)
512#[allow(clippy::too_many_arguments)]
513async fn generate_message_no_streaming(
514    provider: &Arc<dyn LLMProvider>,
515    diff: &str,
516    stats: &DiffStats,
517    feedbacks: &[String],
518    verbose: bool,
519    branch_name: &Option<String>,
520    custom_prompt: &Option<String>,
521    convention: &Option<crate::config::CommitConvention>,
522    scope_info: &Option<ScopeInfo>,
523) -> Result<String> {
524    let context = CommitContext {
525        files_changed: stats.files_changed.clone(),
526        insertions: stats.insertions,
527        deletions: stats.deletions,
528        branch_name: branch_name.clone(),
529        custom_prompt: custom_prompt.clone(),
530        user_feedback: feedbacks.to_vec(),
531        convention: convention.clone(),
532        scope_info: scope_info.clone(),
533    };
534
535    // Build prompt
536    let (system, user) = crate::llm::prompt::build_commit_prompt_split(
537        diff,
538        &context,
539        context.custom_prompt.as_deref(),
540        context.convention.as_ref(),
541    );
542
543    // Display prompt in verbose mode
544    if verbose {
545        // JSON mode: output to stderr (stdout reserved for JSON), no color
546        print_verbose_prompt(&system, &user, true, false);
547    }
548
549    // Use the non-streaming API directly
550    provider.send_prompt(&system, &user, None).await
551}
552
553/// JSON format successfully output
554fn output_json_success(message: &str, stats: &DiffStats, committed: bool) -> Result<()> {
555    let output = JsonOutput {
556        success: true,
557        data: Some(CommitData {
558            message: message.to_string(),
559            diff_stats: stats.into(),
560            committed,
561        }),
562        error: None,
563    };
564    println!("{}", serde_json::to_string_pretty(&output)?);
565    Ok(())
566}
567
568/// Display prompt details in verbose mode.
569///
570/// `to_stderr`: use stderr (for JSON mode where stdout is reserved)
571/// `colored`: apply color formatting
572fn print_verbose_prompt(system: &str, user: &str, to_stderr: bool, colored: bool) {
573    macro_rules! vprintln {
574        ($($arg:tt)*) => {
575            if to_stderr {
576                eprintln!($($arg)*);
577            } else {
578                println!($($arg)*);
579            }
580        };
581    }
582
583    if colored {
584        vprintln!(
585            "\n{}",
586            rust_i18n::t!("commit.verbose.generated_prompt")
587                .cyan()
588                .bold()
589        );
590        vprintln!("{}", rust_i18n::t!("commit.verbose.system_prompt").cyan());
591        vprintln!("{}", system);
592        vprintln!("{}", rust_i18n::t!("commit.verbose.user_message").cyan());
593        vprintln!("{}", user);
594        vprintln!(
595            "{}\n",
596            rust_i18n::t!("commit.verbose.divider").cyan().bold()
597        );
598    } else {
599        vprintln!("\n{}", rust_i18n::t!("commit.verbose.generated_prompt"));
600        vprintln!("{}", rust_i18n::t!("commit.verbose.system_prompt"));
601        vprintln!("{}", system);
602        vprintln!("{}", rust_i18n::t!("commit.verbose.user_message"));
603        vprintln!("{}", user);
604        vprintln!("{}\n", rust_i18n::t!("commit.verbose.divider"));
605    }
606}
607
608/// Public wrapper for `compute_scope_info` (used by split module).
609pub(crate) fn compute_scope_info_pub(
610    files_changed: &[String],
611    config: &AppConfig,
612) -> Option<ScopeInfo> {
613    compute_scope_info(files_changed, config)
614}
615
616/// Calculate workspace scope information
617///
618/// Detect workspace configuration from git root and infer the scope of changed files.
619/// Supports manual configuration override automatic detection. Returns None (non-fatal) if detection fails.
620fn compute_scope_info(files_changed: &[String], config: &AppConfig) -> Option<ScopeInfo> {
621    if !config.workspace.enabled {
622        return None;
623    }
624
625    let root = crate::git::find_git_root()?;
626
627    // Build WorkspaceInfo: Manual configuration takes precedence, otherwise automatic detection
628    let workspace_info = if let Some(ref manual_members) = config.workspace.members {
629        crate::workspace::WorkspaceInfo {
630            workspace_types: vec![],
631            members: manual_members
632                .iter()
633                .map(|p| crate::workspace::WorkspaceMember {
634                    prefix: crate::workspace::glob_pattern_to_prefix(p),
635                    pattern: p.clone(),
636                })
637                .collect(),
638            root,
639        }
640    } else {
641        crate::workspace::detect_workspace(&root)?
642    };
643
644    // Output detection results
645    if !workspace_info.workspace_types.is_empty() {
646        let type_str = workspace_info
647            .workspace_types
648            .iter()
649            .map(|t| t.to_string())
650            .collect::<Vec<_>>()
651            .join(", ");
652        tracing::debug!(
653            "{}",
654            rust_i18n::t!(
655                "workspace.detected",
656                "type" = type_str,
657                count = workspace_info.members.len()
658            )
659        );
660    }
661
662    let scope = crate::workspace::scope::infer_scope(files_changed, &workspace_info, None);
663
664    // Apply scope_mappings remapping
665    let suggested = scope.suggested_scope.map(|s| {
666        config
667            .workspace
668            .scope_mappings
669            .get(&s)
670            .cloned()
671            .unwrap_or(s)
672    });
673
674    if let Some(ref s) = suggested {
675        tracing::debug!("{}", rust_i18n::t!("workspace.scope_suggestion", scope = s));
676    }
677
678    Some(ScopeInfo {
679        workspace_types: workspace_info
680            .workspace_types
681            .iter()
682            .map(|t| t.to_string())
683            .collect(),
684        packages: scope.packages,
685        suggested_scope: suggested,
686        has_root_changes: !scope.root_files.is_empty(),
687    })
688}
689
690/// Get diff based on commit mode.
691///
692/// - Amend: HEAD commit diff, optionally combined with new staged changes.
693/// - Normal: staged diff (caller must check `has_staged_changes` before calling).
694fn get_diff(repo: &dyn GitOperations, amend: bool) -> Result<String> {
695    if amend {
696        let commit_diff = repo.get_commit_diff("HEAD")?;
697        if repo.has_staged_changes()? {
698            let staged_diff = repo.get_staged_diff()?;
699            Ok(format!("{}\n{}", commit_diff, staged_diff))
700        } else {
701            Ok(commit_diff)
702        }
703    } else {
704        repo.get_staged_diff()
705    }
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use pretty_assertions::assert_eq;
712
713    // === format_message_header test ===
714
715    #[test]
716    fn test_format_message_header_first_attempt() {
717        let header = format_message_header(0);
718        assert_eq!(header, "Generated commit message:");
719    }
720
721    #[test]
722    fn test_format_message_header_second_attempt() {
723        let header = format_message_header(1);
724        assert_eq!(header, "Regenerated commit message (attempt 2):");
725    }
726
727    #[test]
728    fn test_format_message_header_third_attempt() {
729        let header = format_message_header(2);
730        assert_eq!(header, "Regenerated commit message (attempt 3):");
731    }
732
733    // === format_edited_header test ===
734
735    #[test]
736    fn test_format_edited_header() {
737        let header = format_edited_header();
738        assert_eq!(header, "Updated commit message:");
739    }
740}