Skip to main content

sr_ai/commands/
commit.rs

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    /// Only analyze staged changes
29    #[arg(short, long)]
30    pub staged: bool,
31
32    /// Additional context or instructions for commit generation
33    #[arg(short = 'M', long)]
34    pub message: Option<String>,
35
36    /// Display plan without executing
37    #[arg(short = 'n', long)]
38    pub dry_run: bool,
39
40    /// Skip confirmation prompt
41    #[arg(short, long)]
42    pub yes: bool,
43
44    /// Bypass cache (always call AI)
45    #[arg(long)]
46    pub no_cache: bool,
47}
48
49use crate::prompts;
50
51enum CacheStatus {
52    /// No cache used (--no-cache, or cache unavailable)
53    None,
54    /// Exact cache hit
55    Cached,
56    /// DAG patch — plan reused with targeted changes
57    Patched,
58    /// Patch + AI for unplaced files
59    PatchedWithAi,
60}
61
62pub async fn run(args: &CommitArgs, backend_config: &BackendConfig) -> Result<()> {
63    ui::header("sr commit");
64
65    // Phase 1: Discover repository
66    let repo = GitRepo::discover()?;
67    ui::phase_ok("Repository found", None);
68
69    // Load project config for commit types and pattern
70    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    // Phase 2: Check for changes
78    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    // Phase 3: Resolve AI backend
99    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    // Build cache manager (may be None if cache dir unavailable)
112    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    // Snapshot the working tree before the agent runs.
125    // If anything goes wrong (agent failure, unexpected mutations),
126    // the guard restores the working tree from the snapshot on drop.
127    let snapshot = SnapshotGuard::new(&repo)?;
128    ui::phase_ok("Working tree snapshot saved", None);
129
130    // Phase 4: Generate plan (cache or AI)
131    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                // Pure patch — no AI needed. Plan can execute directly.
148                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                // Unplaced files need AI to integrate into the plan.
170                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    // Validate: merge commits with shared files
238    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    // Store in cache (before display/execute so dry-runs populate cache too)
248    if let Some(cache) = &cache {
249        cache.store(&plan, &backend_name, &model_name);
250    }
251
252    // Display plan
253    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    // Confirm
269    if !args.yes && !ui::confirm("Execute plan? [y/N]")? {
270        bail!(crate::error::SrAiError::Cancelled);
271    }
272
273    // Pre-validate commit messages against the configured pattern
274    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
283    execute_plan(&repo, &plan)?;
284
285    // All commits succeeded (or at least some did) — clear the snapshot
286    snapshot.success();
287
288    Ok(())
289}
290
291/// Validate that no file appears in multiple commits. If duplicates are found,
292/// merge affected commits into one.
293fn validate_plan(plan: CommitPlan) -> CommitPlan {
294    // Count file occurrences
295    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    // Partition into tainted (has any dupe file) and clean
313    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    // Merge all tainted commits into one
326    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    // Re-number: merged first, then clean commits
371    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
380/// Parse a commit plan from JSON text, tolerating duplicate fields.
381fn parse_plan(text: &str) -> Result<CommitPlan> {
382    // Parse to Value first — serde_json::Value keeps the last value for duplicate keys,
383    // while #[derive(Deserialize)] rejects them. This handles AI responses that
384    // occasionally produce duplicate fields when schema is embedded in the prompt.
385    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
390/// Spawn a background task that renders AI events (tool calls) above a spinner.
391fn 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
406/// Format the detail string for spinner_done, including usage if available.
407fn 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
439/// Validate that all commit messages match the configured pattern.
440/// Returns a list of (index, message, error) for invalid commits.
441fn 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            // If the pattern itself is invalid, report all commits as invalid
446            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    // Unstage everything first
471    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        // Stage files for this commit
481        for file in &commit.files {
482            let ok = repo.stage_file(file)?;
483            ui::file_staged(file, ok);
484        }
485
486        // Build full commit message
487        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        // Create commit (only if there are staged files)
502        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                    // Unstage files from the failed commit so the next commit starts clean
513                    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        // Two tainted merged into one + one clean = 2
592        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); // 1-indexed
659        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}