1use crate::ai::{AiEvent, AiRequest, BackendConfig, resolve_backend};
2use crate::cache::{CacheLookup, CacheManager};
3use crate::git::{GitRepo, SnapshotGuard};
4use crate::ui;
5use anyhow::{Context, Result, bail};
6use indicatif::ProgressBar;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use tokio::sync::mpsc;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CommitPlan {
14 pub commits: Vec<PlannedCommit>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct PlannedCommit {
19 pub order: Option<u32>,
20 pub message: String,
21 pub body: Option<String>,
22 pub footer: Option<String>,
23 pub files: Vec<String>,
24}
25
26#[derive(Debug, clap::Args)]
27pub struct CommitArgs {
28 #[arg(short, long)]
30 pub staged: bool,
31
32 #[arg(short = 'M', long)]
34 pub message: Option<String>,
35
36 #[arg(short = 'n', long)]
38 pub dry_run: bool,
39
40 #[arg(short, long)]
42 pub yes: bool,
43
44 #[arg(long)]
46 pub no_cache: bool,
47}
48
49use crate::prompts;
50
51enum CacheStatus {
52 None,
54 Cached,
56 Patched,
58 PatchedWithAi,
60}
61
62pub async fn run(args: &CommitArgs, backend_config: &BackendConfig) -> Result<()> {
63 ui::header("sr commit");
64
65 let repo = GitRepo::discover()?;
67 ui::phase_ok("Repository found", None);
68
69 let config = sr_core::config::ReleaseConfig::find_config(repo.root().as_path())
71 .map(|(path, _)| sr_core::config::ReleaseConfig::load(&path))
72 .transpose()?
73 .unwrap_or_default();
74 let type_names: Vec<&str> = config.types.iter().map(|t| t.name.as_str()).collect();
75 let system_prompt = prompts::commit::system_prompt(&config.commit_pattern, &type_names);
76
77 let has_changes = if args.staged {
79 repo.has_staged_changes()?
80 } else {
81 repo.has_any_changes()?
82 };
83
84 if !has_changes {
85 bail!(crate::error::SrAiError::NoChanges);
86 }
87
88 let statuses = repo.file_statuses().unwrap_or_default();
89 let file_count = statuses.len();
90 ui::phase_ok(
91 "Changes detected",
92 Some(&format!(
93 "{file_count} file{}",
94 if file_count == 1 { "" } else { "s" }
95 )),
96 );
97
98 let backend = resolve_backend(backend_config).await?;
100 let backend_name = backend.name().to_string();
101 let model_name = backend_config
102 .model
103 .as_deref()
104 .unwrap_or("default")
105 .to_string();
106 ui::phase_ok(
107 "Backend resolved",
108 Some(&format!("{backend_name} ({model_name})")),
109 );
110
111 let cache = if args.no_cache {
113 None
114 } else {
115 CacheManager::new(
116 repo.root(),
117 args.staged,
118 args.message.as_deref(),
119 &backend_name,
120 &model_name,
121 )
122 };
123
124 let snapshot = SnapshotGuard::new(&repo)?;
128 ui::phase_ok("Working tree snapshot saved", None);
129
130 let (mut plan, cache_status) = match cache.as_ref().map(|c| c.lookup()) {
132 Some(CacheLookup::ExactHit(cached_plan)) => {
133 ui::phase_ok(
134 "Plan loaded",
135 Some(&format!("{} commits · cached", cached_plan.commits.len())),
136 );
137 (cached_plan, CacheStatus::Cached)
138 }
139 Some(CacheLookup::PatchHit {
140 plan: patched_plan,
141 dirty_commits,
142 changed_files,
143 unplaced_files,
144 delta_summary,
145 }) => {
146 if unplaced_files.is_empty() {
147 let dirty_label = if dirty_commits.is_empty() {
149 "no dirty commits".to_string()
150 } else {
151 format!(
152 "{} dirty commit{}",
153 dirty_commits.len(),
154 if dirty_commits.len() == 1 { "" } else { "s" }
155 )
156 };
157 ui::phase_ok(
158 "Plan patched",
159 Some(&format!(
160 "{} commits · {} · {} changed file{}",
161 patched_plan.commits.len(),
162 dirty_label,
163 changed_files.len(),
164 if changed_files.len() == 1 { "" } else { "s" }
165 )),
166 );
167 (patched_plan, CacheStatus::Patched)
168 } else {
169 let spinner = ui::spinner(&format!(
171 "Placing {} new file{} with {backend_name}...",
172 unplaced_files.len(),
173 if unplaced_files.len() == 1 { "" } else { "s" }
174 ));
175 let (tx, event_handler) = spawn_event_handler(&spinner);
176
177 let user_prompt = prompts::commit::patch_prompt(
178 args.staged,
179 &repo.root().to_string_lossy(),
180 args.message.as_deref(),
181 &patched_plan,
182 &unplaced_files,
183 &delta_summary,
184 );
185
186 let request = AiRequest {
187 system_prompt: system_prompt.clone(),
188 user_prompt,
189 json_schema: Some(prompts::commit::SCHEMA.to_string()),
190 working_dir: repo.root().to_string_lossy().to_string(),
191 };
192
193 let response = backend.request(&request, Some(tx)).await?;
194 let _ = event_handler.await;
195
196 let p: CommitPlan = parse_plan(&response.text)?;
197
198 let detail = format_done_detail(p.commits.len(), "patched", &response.usage);
199 ui::spinner_done(&spinner, Some(&detail));
200
201 (p, CacheStatus::PatchedWithAi)
202 }
203 }
204 _ => {
205 let spinner = ui::spinner(&format!("Analyzing changes with {backend_name}..."));
206 let (tx, event_handler) = spawn_event_handler(&spinner);
207
208 let user_prompt = prompts::commit::user_prompt(
209 args.staged,
210 &repo.root().to_string_lossy(),
211 args.message.as_deref(),
212 );
213
214 let request = AiRequest {
215 system_prompt: system_prompt.clone(),
216 user_prompt,
217 json_schema: Some(prompts::commit::SCHEMA.to_string()),
218 working_dir: repo.root().to_string_lossy().to_string(),
219 };
220
221 let response = backend.request(&request, Some(tx)).await?;
222 let _ = event_handler.await;
223
224 let p: CommitPlan = parse_plan(&response.text)?;
225
226 let detail = format_done_detail(p.commits.len(), "", &response.usage);
227 ui::spinner_done(&spinner, Some(&detail));
228
229 (p, CacheStatus::None)
230 }
231 };
232
233 if plan.commits.is_empty() {
234 bail!(crate::error::SrAiError::EmptyPlan);
235 }
236
237 let pre_validate_count = plan.commits.len();
239 plan = validate_plan(plan);
240 if plan.commits.len() < pre_validate_count {
241 ui::warn(&format!(
242 "Shared files detected — merged {} commits into 1",
243 pre_validate_count - plan.commits.len() + 1
244 ));
245 }
246
247 if let Some(cache) = &cache {
249 cache.store(&plan, &backend_name, &model_name);
250 }
251
252 let cache_label: Option<&str> = match &cache_status {
254 CacheStatus::Cached => Some("cached"),
255 CacheStatus::Patched => Some("patched"),
256 CacheStatus::PatchedWithAi => Some("patched+ai"),
257 CacheStatus::None => None,
258 };
259 ui::display_plan(&plan, &statuses, cache_label);
260
261 if args.dry_run {
262 ui::info("Dry run — no commits created");
263 println!();
264 snapshot.success();
265 return Ok(());
266 }
267
268 if !args.yes && !ui::confirm("Execute plan? [y/N]")? {
270 bail!(crate::error::SrAiError::Cancelled);
271 }
272
273 let invalid = validate_messages(&plan, &config.commit_pattern);
275 if !invalid.is_empty() {
276 ui::invalid_messages(&invalid);
277 if !args.yes && !ui::confirm("Continue anyway? Invalid commits will likely fail. [y/N]")? {
278 bail!(crate::error::SrAiError::Cancelled);
279 }
280 }
281
282 execute_plan(&repo, &plan)?;
284
285 snapshot.success();
287
288 Ok(())
289}
290
291fn validate_plan(plan: CommitPlan) -> CommitPlan {
294 let mut file_counts: HashMap<String, usize> = HashMap::new();
296 for commit in &plan.commits {
297 for file in &commit.files {
298 *file_counts.entry(file.clone()).or_default() += 1;
299 }
300 }
301
302 let dupes: Vec<&String> = file_counts
303 .iter()
304 .filter(|(_, count)| **count > 1)
305 .map(|(file, _)| file)
306 .collect();
307
308 if dupes.is_empty() {
309 return plan;
310 }
311
312 let mut tainted = Vec::new();
314 let mut clean = Vec::new();
315
316 for commit in plan.commits {
317 let is_tainted = commit.files.iter().any(|f| dupes.contains(&f));
318 if is_tainted {
319 tainted.push(commit);
320 } else {
321 clean.push(commit);
322 }
323 }
324
325 let merged_message = tainted
327 .first()
328 .map(|c| c.message.clone())
329 .unwrap_or_default();
330
331 let merged_body = tainted
332 .iter()
333 .filter_map(|c| c.body.as_ref())
334 .filter(|b| !b.is_empty())
335 .cloned()
336 .collect::<Vec<_>>()
337 .join("\n\n");
338
339 let merged_footer = tainted
340 .iter()
341 .filter_map(|c| c.footer.as_ref())
342 .filter(|f| !f.is_empty())
343 .cloned()
344 .collect::<Vec<_>>()
345 .join("\n");
346
347 let mut merged_files: Vec<String> = tainted
348 .iter()
349 .flat_map(|c| c.files.iter().cloned())
350 .collect();
351 merged_files.sort();
352 merged_files.dedup();
353
354 let merged_commit = PlannedCommit {
355 order: Some(1),
356 message: merged_message,
357 body: if merged_body.is_empty() {
358 None
359 } else {
360 Some(merged_body)
361 },
362 footer: if merged_footer.is_empty() {
363 None
364 } else {
365 Some(merged_footer)
366 },
367 files: merged_files,
368 };
369
370 let mut result = vec![merged_commit];
372 for (i, mut commit) in clean.into_iter().enumerate() {
373 commit.order = Some(i as u32 + 2);
374 result.push(commit);
375 }
376
377 CommitPlan { commits: result }
378}
379
380fn parse_plan(text: &str) -> Result<CommitPlan> {
382 let value: serde_json::Value =
386 serde_json::from_str(text).context("failed to parse JSON from AI response")?;
387 serde_json::from_value(value).context("failed to parse commit plan from AI response")
388}
389
390fn spawn_event_handler(
392 spinner: &ProgressBar,
393) -> (mpsc::UnboundedSender<AiEvent>, tokio::task::JoinHandle<()>) {
394 let (tx, mut rx) = mpsc::unbounded_channel();
395 let pb = spinner.clone();
396 let handle = tokio::spawn(async move {
397 while let Some(event) = rx.recv().await {
398 match event {
399 AiEvent::ToolCall { input, .. } => ui::tool_call(&pb, &input),
400 }
401 }
402 });
403 (tx, handle)
404}
405
406fn format_done_detail(
408 commit_count: usize,
409 extra: &str,
410 usage: &Option<crate::ai::AiUsage>,
411) -> String {
412 let commits = format!(
413 "{commit_count} commit{}",
414 if commit_count == 1 { "" } else { "s" }
415 );
416 let extra_part = if extra.is_empty() {
417 String::new()
418 } else {
419 format!(" · {extra}")
420 };
421 let usage_part = match usage {
422 Some(u) => {
423 let cost = u
424 .cost_usd
425 .map(|c| format!(" · ${c:.4}"))
426 .unwrap_or_default();
427 format!(
428 " · {} in / {} out{}",
429 ui::format_tokens(u.input_tokens),
430 ui::format_tokens(u.output_tokens),
431 cost
432 )
433 }
434 None => String::new(),
435 };
436 format!("{commits}{extra_part}{usage_part}")
437}
438
439fn validate_messages(plan: &CommitPlan, commit_pattern: &str) -> Vec<(usize, String, String)> {
442 let re = match Regex::new(commit_pattern) {
443 Ok(re) => re,
444 Err(e) => {
445 return plan
447 .commits
448 .iter()
449 .enumerate()
450 .map(|(i, c)| (i + 1, c.message.clone(), format!("invalid pattern: {e}")))
451 .collect();
452 }
453 };
454
455 plan.commits
456 .iter()
457 .enumerate()
458 .filter(|(_, c)| !re.is_match(&c.message))
459 .map(|(i, c)| {
460 (
461 i + 1,
462 c.message.clone(),
463 format!("does not match pattern: {commit_pattern}"),
464 )
465 })
466 .collect()
467}
468
469fn execute_plan(repo: &GitRepo, plan: &CommitPlan) -> Result<()> {
470 repo.reset_head()?;
472
473 let total = plan.commits.len();
474 let mut created: Vec<(String, String)> = Vec::new();
475 let mut failed: Vec<(usize, String, String)> = Vec::new();
476
477 for (i, commit) in plan.commits.iter().enumerate() {
478 ui::commit_start(i + 1, total, &commit.message);
479
480 for file in &commit.files {
482 let ok = repo.stage_file(file)?;
483 ui::file_staged(file, ok);
484 }
485
486 let mut full_message = commit.message.clone();
488 if let Some(body) = &commit.body
489 && !body.is_empty()
490 {
491 full_message.push_str("\n\n");
492 full_message.push_str(body);
493 }
494 if let Some(footer) = &commit.footer
495 && !footer.is_empty()
496 {
497 full_message.push_str("\n\n");
498 full_message.push_str(footer);
499 }
500
501 if repo.has_staged_after_add()? {
503 match repo.commit(&full_message) {
504 Ok(()) => {
505 let sha = repo.head_short().unwrap_or_else(|_| "???????".to_string());
506 ui::commit_created(&sha);
507 created.push((sha, commit.message.clone()));
508 }
509 Err(e) => {
510 ui::commit_failed(&format!("{e:#}"));
511 failed.push((i + 1, commit.message.clone(), format!("{e:#}")));
512 repo.reset_head()?;
514 }
515 }
516 } else {
517 ui::commit_skipped();
518 }
519 }
520
521 ui::summary(&created);
522
523 if !failed.is_empty() {
524 ui::failed_commits(&failed);
525 if created.is_empty() {
526 bail!("all {} commits failed", failed.len());
527 }
528 }
529
530 Ok(())
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
538 fn validate_plan_no_dupes() {
539 let plan = CommitPlan {
540 commits: vec![
541 PlannedCommit {
542 order: Some(1),
543 message: "feat: add foo".into(),
544 body: Some("reason".into()),
545 footer: None,
546 files: vec!["a.rs".into()],
547 },
548 PlannedCommit {
549 order: Some(2),
550 message: "fix: fix bar".into(),
551 body: Some("reason".into()),
552 footer: None,
553 files: vec!["b.rs".into()],
554 },
555 ],
556 };
557
558 let result = validate_plan(plan);
559 assert_eq!(result.commits.len(), 2);
560 }
561
562 #[test]
563 fn validate_plan_merges_dupes() {
564 let plan = CommitPlan {
565 commits: vec![
566 PlannedCommit {
567 order: Some(1),
568 message: "feat: add foo".into(),
569 body: Some("reason 1".into()),
570 footer: None,
571 files: vec!["shared.rs".into(), "a.rs".into()],
572 },
573 PlannedCommit {
574 order: Some(2),
575 message: "fix: fix bar".into(),
576 body: Some("reason 2".into()),
577 footer: None,
578 files: vec!["shared.rs".into(), "b.rs".into()],
579 },
580 PlannedCommit {
581 order: Some(3),
582 message: "docs: update readme".into(),
583 body: Some("docs".into()),
584 footer: None,
585 files: vec!["README.md".into()],
586 },
587 ],
588 };
589
590 let result = validate_plan(plan);
591 assert_eq!(result.commits.len(), 2);
593 assert_eq!(result.commits[0].message, "feat: add foo");
594 assert!(result.commits[0].files.contains(&"shared.rs".to_string()));
595 assert!(result.commits[0].files.contains(&"a.rs".to_string()));
596 assert!(result.commits[0].files.contains(&"b.rs".to_string()));
597 assert_eq!(result.commits[1].message, "docs: update readme");
598 assert_eq!(result.commits[1].order, Some(2));
599 }
600
601 #[test]
602 fn validate_messages_all_valid() {
603 let plan = CommitPlan {
604 commits: vec![
605 PlannedCommit {
606 order: Some(1),
607 message: "feat: add foo".into(),
608 body: None,
609 footer: None,
610 files: vec![],
611 },
612 PlannedCommit {
613 order: Some(2),
614 message: "fix(core): null check".into(),
615 body: None,
616 footer: None,
617 files: vec![],
618 },
619 ],
620 };
621
622 let pattern = sr_core::commit::DEFAULT_COMMIT_PATTERN;
623 let invalid = validate_messages(&plan, pattern);
624 assert!(invalid.is_empty());
625 }
626
627 #[test]
628 fn validate_messages_catches_invalid() {
629 let plan = CommitPlan {
630 commits: vec![
631 PlannedCommit {
632 order: Some(1),
633 message: "feat: add foo".into(),
634 body: None,
635 footer: None,
636 files: vec![],
637 },
638 PlannedCommit {
639 order: Some(2),
640 message: "not a conventional commit".into(),
641 body: None,
642 footer: None,
643 files: vec![],
644 },
645 PlannedCommit {
646 order: Some(3),
647 message: "fix: valid one".into(),
648 body: None,
649 footer: None,
650 files: vec![],
651 },
652 ],
653 };
654
655 let pattern = sr_core::commit::DEFAULT_COMMIT_PATTERN;
656 let invalid = validate_messages(&plan, pattern);
657 assert_eq!(invalid.len(), 1);
658 assert_eq!(invalid[0].0, 2); assert_eq!(invalid[0].1, "not a conventional commit");
660 }
661
662 #[test]
663 fn validate_messages_invalid_pattern() {
664 let plan = CommitPlan {
665 commits: vec![PlannedCommit {
666 order: Some(1),
667 message: "feat: add foo".into(),
668 body: None,
669 footer: None,
670 files: vec![],
671 }],
672 };
673
674 let invalid = validate_messages(&plan, "[invalid regex");
675 assert_eq!(invalid.len(), 1);
676 assert!(invalid[0].2.contains("invalid pattern"));
677 }
678}