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#[derive(Debug, Serialize)]
19pub struct CommitData {
20 pub message: String,
22 pub diff_stats: DiffStatsJson,
24 pub committed: bool,
26}
27
28#[derive(Debug, Serialize)]
30pub struct DiffStatsJson {
31 pub files_changed: Vec<String>,
33 pub insertions: usize,
35 pub deletions: usize,
37 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
52pub 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#[allow(dead_code)] async 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 let initial_feedbacks = if options.feedback.is_empty() {
77 vec![]
78 } else {
79 vec![options.feedback.join(" ")]
80 };
81
82 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 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 if options.format.is_json() {
103 return handle_json_mode(options, config, repo, provider, &initial_feedbacks).await;
104 }
105
106 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 let stats = repo.get_diff_stats(&diff)?;
115
116 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 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 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 let should_edit = config.commit.allow_edit && !options.no_edit;
164 let max_retries = config.commit.max_retries;
165
166 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
232async 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#[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 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 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 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 if !options.yes && !already_displayed {
324 display_message(&message, attempt, colored);
325 }
326
327 Ok(next_state)
328}
329
330fn 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 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#[allow(clippy::too_many_arguments)] async 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 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 if verbose {
430 print_verbose_prompt(&system, &user, false, true);
431 }
432
433 let use_streaming = config.ui.streaming && provider.supports_streaming();
435 let colored = config.ui.colored;
436
437 if use_streaming {
438 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 output.redisplay_if_cleaned(&message);
455
456 Ok((message, true)) } else {
458 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)) }
473}
474
475fn 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
484fn format_edited_header() -> String {
486 rust_i18n::t!("commit.updated").to_string()
487}
488
489fn 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
501fn 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#[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 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 if verbose {
545 print_verbose_prompt(&system, &user, true, false);
547 }
548
549 provider.send_prompt(&system, &user, None).await
551}
552
553fn 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
568fn 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
608pub(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
616fn 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 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 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 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
690fn 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 #[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 #[test]
736 fn test_format_edited_header() {
737 let header = format_edited_header();
738 assert_eq!(header, "Updated commit message:");
739 }
740}