Skip to main content

orch/cli/
commit.rs

1use crate::cli;
2use crate::cmd::SyncCommandErrorContext;
3use crate::config;
4use crate::control;
5use crate::engine::runner::commit;
6use crate::engine::runner::diff::parse_unified_diff_hunks;
7use anyhow::Context;
8use std::collections::{BTreeMap, BTreeSet};
9use std::env;
10use std::io::{self, Write};
11use std::iter;
12use std::path::Path;
13use std::process::Command;
14
15/// Default cap on diff bytes sent to the LLM per commit group.
16const DEFAULT_MAX_DIFF_BYTES: usize = 32 * 1024;
17
18#[derive(Debug, Clone)]
19struct ChangedFile {
20    path: String,
21    old_path: Option<String>,
22}
23
24#[derive(Debug, Clone)]
25struct CommitPlan {
26    message: String,
27    files: Vec<ChangedFile>,
28}
29
30pub async fn run(
31    dry_run: bool,
32    yes: bool,
33    message_override: Option<String>,
34    no_llm: bool,
35) -> anyhow::Result<()> {
36    ensure_git_repo()?;
37    ensure_no_merge_conflicts()?;
38
39    if !has_changes()? {
40        println!("nothing to commit");
41        return Ok(());
42    }
43
44    // Collect files and diff WITHOUT touching the index, preserving the user's staging state.
45    let files = collect_all_changed_files()?;
46    if files.is_empty() {
47        println!("nothing to commit");
48        return Ok(());
49    }
50
51    let diff_text = get_full_diff()?;
52
53    let mut plans = if let Some(message) = message_override {
54        vec![CommitPlan { message, files }]
55    } else {
56        let mut p = build_plans(files, &diff_text);
57        if !no_llm {
58            enrich_with_llm(&mut p).await;
59        }
60        p
61    };
62
63    print_plan_summary(&plans);
64
65    if dry_run {
66        // Index was never mutated — nothing to undo.
67        return Ok(());
68    }
69
70    if !yes {
71        match prompt_confirmation()? {
72            UserChoice::No => {
73                // Index was never mutated — nothing to undo.
74                println!("aborted");
75                return Ok(());
76            }
77            UserChoice::Edit => {
78                edit_messages(&mut plans)?;
79                execute(&plans).await?;
80                return Ok(());
81            }
82            UserChoice::Yes => {}
83        }
84    }
85
86    execute(&plans).await
87}
88
89/// Replace each plan's heuristic message with an LLM-drafted one.
90///
91/// On any error (no store, no agent, cooldowns, timeout) the heuristic
92/// message survives and a one-line warning is logged.
93async fn enrich_with_llm(plans: &mut [CommitPlan]) {
94    let store = match cli::init_store().await {
95        Ok(s) => s,
96        Err(e) => {
97            tracing::warn!(
98                error = %e,
99                "orch commit: could not open store for LLM message; using heuristic messages"
100            );
101            return;
102        }
103    };
104
105    let agent = match control::get_agent(&store).await {
106        Ok(a) => a,
107        Err(e) => {
108            tracing::warn!(error = %e, "orch commit: could not resolve agent; using heuristic messages");
109            return;
110        }
111    };
112    let model = match control::get_model(&store).await {
113        Ok(m) => m,
114        Err(e) => {
115            tracing::warn!(error = %e, "orch commit: could not resolve model; using heuristic messages");
116            return;
117        }
118    };
119
120    let max_bytes = resolve_max_diff_bytes();
121
122    for plan in plans.iter_mut() {
123        match draft_message_for_plan(&agent, &model, plan, max_bytes).await {
124            Ok(message) => plan.message = message,
125            Err(e) => tracing::warn!(
126                agent = %agent,
127                model = %model,
128                error = %e,
129                "orch commit: LLM draft failed, keeping heuristic message"
130            ),
131        }
132    }
133}
134
135fn resolve_max_diff_bytes() -> usize {
136    config::get("commit.max_diff_bytes")
137        .ok()
138        .and_then(|s| s.parse::<usize>().ok())
139        .filter(|n| *n > 0)
140        .unwrap_or(DEFAULT_MAX_DIFF_BYTES)
141}
142
143/// Call the agent once for this plan and return a cleaned commit message.
144async fn draft_message_for_plan(
145    agent: &str,
146    model: &str,
147    plan: &CommitPlan,
148    max_bytes: usize,
149) -> anyhow::Result<String> {
150    let paths: Vec<String> = plan
151        .files
152        .iter()
153        .flat_map(|f| iter::once(f.path.clone()).chain(f.old_path.clone()))
154        .collect();
155
156    let diff_text = diff_for_paths(&paths)?;
157    let (diff_for_prompt, truncated) = truncate_diff(&diff_text, max_bytes);
158
159    let file_list = plan
160        .files
161        .iter()
162        .map(|f| match &f.old_path {
163            Some(old) => format!("- {} -> {}", old, f.path),
164            None => format!("- {}", f.path),
165        })
166        .collect::<Vec<_>>()
167        .join("\n");
168
169    let truncated_note = if truncated {
170        format!(
171            "Note: the diff was truncated to ~{} KB to fit the budget. Base the message on the visible portion.",
172            max_bytes / 1024
173        )
174    } else {
175        String::new()
176    };
177
178    let prompt = include_str!("../../prompts/commit_message.md")
179        .replace("{{FILES}}", &file_list)
180        .replace("{{DIFF}}", &diff_for_prompt)
181        .replace("{{TRUNCATED_NOTE}}", &truncated_note);
182
183    let result = control::invoke_agent(agent, model, "", &prompt).await?;
184    let cleaned = clean_llm_message(&result.text);
185    if cleaned.is_empty() {
186        anyhow::bail!("LLM returned an empty commit message");
187    }
188    Ok(cleaned)
189}
190
191/// Strip fences, leading/trailing whitespace, and obvious chatter from an LLM response.
192fn clean_llm_message(raw: &str) -> String {
193    let trimmed = raw.trim();
194
195    // Remove a leading triple-backtick fence if present (with or without a language tag).
196    let without_open_fence = if let Some(rest) = trimmed.strip_prefix("```") {
197        // Drop the rest of the opening line (language tag) up to the first newline.
198        match rest.find('\n') {
199            Some(i) => &rest[i + 1..],
200            None => rest,
201        }
202    } else {
203        trimmed
204    };
205
206    // Remove a trailing triple-backtick fence.
207    let without_close_fence = without_open_fence
208        .trim_end()
209        .strip_suffix("```")
210        .unwrap_or(without_open_fence);
211
212    without_close_fence.trim().to_string()
213}
214
215/// Truncate the diff at `max_bytes`, snapping to a UTF-8 character boundary.
216fn truncate_diff(diff: &str, max_bytes: usize) -> (String, bool) {
217    if diff.len() <= max_bytes {
218        return (diff.to_string(), false);
219    }
220    let mut cutoff = max_bytes;
221    while cutoff > 0 && !diff.is_char_boundary(cutoff) {
222        cutoff -= 1;
223    }
224    (diff[..cutoff].to_string(), true)
225}
226
227/// Get the combined staged + unstaged + untracked diff scoped to the given paths.
228///
229/// `git diff --cached` and `git diff` never include untracked files, so for any
230/// path returned by `git ls-files --others --exclude-standard` we synthesize a
231/// `new file` diff via `git diff --no-index /dev/null <path>`. Without this the
232/// LLM would see only the filename for brand-new files and fall back to
233/// path-only guessing — defeating the point of the LLM draft.
234///
235/// Uses default unified context (3 lines) so the LLM can see what changed
236/// in surrounding code, unlike `get_full_diff()` which uses --unified=0
237/// because it only feeds the hunk-counting heuristic.
238fn diff_for_paths(paths: &[String]) -> anyhow::Result<String> {
239    let unique: BTreeSet<String> = paths.iter().cloned().collect();
240
241    let untracked_list = git_output(["ls-files", "--others", "--exclude-standard"])?;
242    let untracked: BTreeSet<String> = untracked_list
243        .lines()
244        .map(str::trim)
245        .filter(|l| !l.is_empty())
246        .map(str::to_string)
247        .collect();
248
249    let mut staged_args: Vec<String> = vec![
250        "diff".into(),
251        "--cached".into(),
252        "--no-color".into(),
253        "--no-ext-diff".into(),
254        "--find-renames".into(),
255        "--".into(),
256    ];
257    staged_args.extend(unique.iter().cloned());
258
259    let mut unstaged_args: Vec<String> = vec![
260        "diff".into(),
261        "--no-color".into(),
262        "--no-ext-diff".into(),
263        "--find-renames".into(),
264        "--".into(),
265    ];
266    unstaged_args.extend(unique.iter().cloned());
267
268    let staged = run_git(&staged_args)?;
269    let unstaged = run_git(&unstaged_args)?;
270
271    let mut combined = format!("{staged}{unstaged}");
272    for path in unique.iter().filter(|p| untracked.contains(*p)) {
273        combined.push_str(&diff_for_untracked(path)?);
274    }
275    Ok(combined)
276}
277
278/// Produce a unified diff for an untracked file by diffing it against /dev/null.
279///
280/// `git diff --no-index` exits 1 when the files differ — that is the expected
281/// path here, since we are always comparing to an empty file. Treat exit 0 and
282/// 1 as success and surface anything else as an error.
283fn diff_for_untracked(path: &str) -> anyhow::Result<String> {
284    let out = Command::new("git")
285        .args([
286            "diff",
287            "--no-color",
288            "--no-ext-diff",
289            "--no-index",
290            "--",
291            "/dev/null",
292            path,
293        ])
294        .output_with_context()?;
295    let code = out.status.code();
296    if !matches!(code, Some(0 | 1)) {
297        anyhow::bail!(
298            "git diff --no-index -- /dev/null {path} failed (exit {:?}): {}",
299            code,
300            String::from_utf8_lossy(&out.stderr).trim()
301        );
302    }
303    Ok(String::from_utf8_lossy(&out.stdout).to_string())
304}
305
306fn run_git(args: &[String]) -> anyhow::Result<String> {
307    let out = Command::new("git").args(args).output_with_context()?;
308    if !out.status.success() {
309        anyhow::bail!("git {} failed", args.join(" "));
310    }
311    Ok(String::from_utf8_lossy(&out.stdout).to_string())
312}
313
314/// shipcheck commits exactly the tree that passed, so in a shipcheck repository the groups become one commit
315async fn execute(plans: &[CommitPlan]) -> anyhow::Result<()> {
316    let dir = env::current_dir()?;
317    if commit::Tool::detect(&dir).await != commit::Tool::Shipcheck {
318        return execute_plans(plans);
319    }
320
321    let message = plans
322        .iter()
323        .map(|p| p.message.as_str())
324        .collect::<Vec<_>>()
325        .join("\n\n");
326    match commit::commit_all(&dir, &message, "orch", commit::When::Tested).await? {
327        commit::Outcome::Committed => {
328            println!("committed through shipcheck");
329            Ok(())
330        }
331        commit::Outcome::Nothing => {
332            println!("nothing to commit");
333            Ok(())
334        }
335        commit::Outcome::Refused { reason, .. } => anyhow::bail!(reason),
336    }
337}
338
339fn execute_plans(plans: &[CommitPlan]) -> anyhow::Result<()> {
340    git_reset_unstage_all()?;
341
342    for plan in plans {
343        let mut path_set: BTreeSet<String> = BTreeSet::new();
344        for file in &plan.files {
345            if let Some(old) = &file.old_path {
346                path_set.insert(old.clone());
347            }
348            path_set.insert(file.path.clone());
349        }
350
351        let mut cmd = Command::new("git");
352        cmd.arg("add").arg("-A").arg("--");
353        for path in path_set {
354            cmd.arg(path);
355        }
356        let add = cmd.output_with_context()?;
357        if !add.status.success() {
358            anyhow::bail!(
359                "failed to stage commit group: {}",
360                String::from_utf8_lossy(&add.stderr).trim()
361            );
362        }
363
364        let commit = Command::new("git")
365            .args(["commit", "-m", &plan.message])
366            .output_with_context()
367            .context("failed to run git commit")?;
368        if !commit.status.success() {
369            anyhow::bail!(
370                "git commit failed: {}",
371                String::from_utf8_lossy(&commit.stderr).trim()
372            );
373        }
374    }
375
376    Ok(())
377}
378
379enum UserChoice {
380    Yes,
381    No,
382    Edit,
383}
384
385fn prompt_confirmation() -> anyhow::Result<UserChoice> {
386    print!("\nCommit these changes? [y/n/e] ");
387    io::stdout().flush().ok();
388    let mut input = String::new();
389    io::stdin().read_line(&mut input)?;
390    let value = input.trim().to_ascii_lowercase();
391    Ok(match value.as_str() {
392        "y" | "yes" => UserChoice::Yes,
393        "e" | "edit" => UserChoice::Edit,
394        _ => UserChoice::No,
395    })
396}
397
398fn edit_messages(plans: &mut [CommitPlan]) -> anyhow::Result<()> {
399    println!("\nEdit commit messages (press enter to keep current message):");
400    for (i, plan) in plans.iter_mut().enumerate() {
401        println!("\n{}. {}", i + 1, plan.message);
402        print!("new message> ");
403        io::stdout().flush().ok();
404        let mut input = String::new();
405        io::stdin().read_line(&mut input)?;
406        let next = input.trim();
407        if !next.is_empty() {
408            plan.message = next.to_string();
409        }
410    }
411    Ok(())
412}
413
414fn print_plan_summary(plans: &[CommitPlan]) {
415    let file_count: usize = plans.iter().map(|p| p.files.len()).sum();
416    println!(
417        "\nProposed {} commits from {} changed files:\n",
418        plans.len(),
419        file_count
420    );
421    for (i, plan) in plans.iter().enumerate() {
422        println!(
423            "{}. {}",
424            i + 1,
425            plan.message.lines().next().unwrap_or("commit")
426        );
427        for file in &plan.files {
428            if let Some(old) = &file.old_path {
429                println!("   - {} -> {}", old, file.path);
430            } else {
431                println!("   - {}", file.path);
432            }
433        }
434        if plan.message.lines().count() > 1 {
435            for body_line in plan.message.lines().skip(1) {
436                println!("   {body_line}");
437            }
438        }
439        println!();
440    }
441}
442
443fn build_plans(files: Vec<ChangedFile>, diff_text: &str) -> Vec<CommitPlan> {
444    let hunks = parse_unified_diff_hunks(diff_text);
445    let mut hunk_counts: BTreeMap<&str, usize> = BTreeMap::new();
446    for file in &hunks {
447        hunk_counts.insert(file.path.as_str(), file.hunks.len());
448    }
449
450    let mut groups: BTreeMap<String, Vec<ChangedFile>> = BTreeMap::new();
451
452    for file in files {
453        let key = group_key(&file.path);
454        groups.entry(key).or_default().push(file);
455    }
456
457    let mut plans = Vec::new();
458    for (_, mut grouped_files) in groups {
459        grouped_files.sort_by(|a, b| a.path.cmp(&b.path));
460        let message = message_for_group(&grouped_files, &hunk_counts);
461        plans.push(CommitPlan {
462            message,
463            files: grouped_files,
464        });
465    }
466
467    plans.sort_by(|a, b| a.message.cmp(&b.message));
468    plans
469}
470
471fn group_key(path: &str) -> String {
472    if is_doc(path) {
473        return "docs".to_string();
474    }
475    if is_config(path) {
476        return "config".to_string();
477    }
478
479    if is_test(path) {
480        if let Some(module) = test_module(path) {
481            return format!("test:{module}");
482        }
483        return "test".to_string();
484    }
485
486    let dir = Path::new(path)
487        .parent()
488        .and_then(|p| p.to_str())
489        .unwrap_or("")
490        .to_string();
491
492    if dir.is_empty() {
493        "root".to_string()
494    } else {
495        let parts: Vec<&str> = dir.split('/').collect();
496        if parts.first().copied() == Some("src") && parts.len() > 1 {
497            format!("src:{}", parts[1])
498        } else {
499            format!("dir:{dir}")
500        }
501    }
502}
503
504fn message_for_group(files: &[ChangedFile], hunk_counts: &BTreeMap<&str, usize>) -> String {
505    let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
506
507    let kind = if paths.iter().all(|p| is_doc(p)) {
508        "docs"
509    } else if paths.iter().all(|p| is_test(p)) {
510        "test"
511    } else if paths.iter().all(|p| is_config(p)) {
512        "chore"
513    } else if files.iter().any(|f| f.old_path.is_some()) {
514        "refactor"
515    } else {
516        "feat"
517    };
518
519    let scope = infer_scope(&paths);
520    let subject = if files.len() == 1 {
521        format!("update {}", paths[0])
522    } else {
523        format!("update {} files", scope.as_deref().unwrap_or("project"))
524    };
525
526    let header = if let Some(scope) = &scope {
527        format!("{kind}({scope}): {subject}")
528    } else {
529        format!("{kind}: {subject}")
530    };
531
532    let total_hunks: usize = files
533        .iter()
534        .map(|f| hunk_counts.get(f.path.as_str()).copied().unwrap_or(0))
535        .sum();
536
537    if total_hunks > 3 || files.len() > 2 {
538        let mut body = String::new();
539        body.push_str("\n\nIncludes:\n");
540        for file in files {
541            body.push_str("- ");
542            body.push_str(&file.path);
543            body.push('\n');
544        }
545        format!("{header}{body}")
546    } else {
547        header
548    }
549}
550
551fn infer_scope(paths: &[&str]) -> Option<String> {
552    let first = paths.first()?;
553    let parent = Path::new(first)
554        .parent()
555        .and_then(|p| p.file_name())
556        .and_then(|n| n.to_str())
557        .map(ToString::to_string);
558
559    if paths.iter().all(|p| {
560        Path::new(p)
561            .parent()
562            .and_then(|pp| pp.file_name())
563            .and_then(|n| n.to_str())
564            .is_some_and(|s| Some(s.to_string()) == parent)
565    }) {
566        parent
567    } else {
568        None
569    }
570}
571
572fn is_doc(path: &str) -> bool {
573    Path::new(path)
574        .extension()
575        .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
576        || path.starts_with("docs/")
577}
578
579fn is_test(path: &str) -> bool {
580    path.starts_with("tests/") || path.contains("_test.") || path.ends_with(".spec.ts")
581}
582
583fn test_module(path: &str) -> Option<String> {
584    let stem = Path::new(path).file_stem()?.to_str()?;
585    Some(stem.trim_end_matches("_test").to_string())
586}
587
588fn is_config(path: &str) -> bool {
589    matches!(
590        path,
591        "Cargo.toml"
592            | "Cargo.lock"
593            | "package.json"
594            | "bun.lockb"
595            | "bun.lock"
596            | "pnpm-lock.yaml"
597            | "package-lock.json"
598    ) || path.starts_with(".github/workflows/")
599}
600
601fn ensure_git_repo() -> anyhow::Result<()> {
602    let out = Command::new("git")
603        .args(["rev-parse", "--is-inside-work-tree"])
604        .output_with_context()?;
605    if !out.status.success() {
606        anyhow::bail!("not a git repository");
607    }
608    Ok(())
609}
610
611fn ensure_no_merge_conflicts() -> anyhow::Result<()> {
612    let out = git_output(["diff", "--name-only", "--diff-filter=U"])?;
613    if !out.trim().is_empty() {
614        anyhow::bail!("merge conflicts detected; resolve conflicts before committing");
615    }
616    Ok(())
617}
618
619fn has_changes() -> anyhow::Result<bool> {
620    let unstaged = Command::new("git")
621        .args(["diff", "--quiet"])
622        .status_with_context()?;
623    let staged = Command::new("git")
624        .args(["diff", "--cached", "--quiet"])
625        .status_with_context()?;
626    let untracked = git_output(["ls-files", "--others", "--exclude-standard"])?;
627
628    Ok(!unstaged.success() || !staged.success() || !untracked.trim().is_empty())
629}
630
631fn git_reset_unstage_all() -> anyhow::Result<()> {
632    let out = Command::new("git").args(["reset"]).output_with_context()?;
633    if !out.status.success() {
634        anyhow::bail!("git reset failed: {}", String::from_utf8_lossy(&out.stderr));
635    }
636    Ok(())
637}
638
639/// Collect all changed files (staged + unstaged + untracked) without touching the index.
640fn collect_all_changed_files() -> anyhow::Result<Vec<ChangedFile>> {
641    let mut seen = BTreeSet::new();
642    let mut files = Vec::new();
643
644    let staged = git_output(["diff", "--cached", "--name-status", "--find-renames"])?;
645    for line in staged.lines().map(str::trim).filter(|l| !l.is_empty()) {
646        if let Some(cf) = parse_name_status_line(line)
647            && seen.insert(cf.path.clone())
648        {
649            files.push(cf);
650        }
651    }
652
653    let unstaged = git_output(["diff", "--name-status", "--find-renames"])?;
654    for line in unstaged.lines().map(str::trim).filter(|l| !l.is_empty()) {
655        if let Some(cf) = parse_name_status_line(line)
656            && seen.insert(cf.path.clone())
657        {
658            files.push(cf);
659        }
660    }
661
662    let untracked = git_output(["ls-files", "--others", "--exclude-standard"])?;
663    for line in untracked.lines().map(str::trim).filter(|l| !l.is_empty()) {
664        if seen.insert(line.to_string()) {
665            files.push(ChangedFile {
666                path: line.to_string(),
667                old_path: None,
668            });
669        }
670    }
671
672    Ok(files)
673}
674
675fn parse_name_status_line(line: &str) -> Option<ChangedFile> {
676    let parts: Vec<&str> = line.split('\t').collect();
677    let status = parts.first()?;
678    if status.starts_with('R') || status.starts_with('C') {
679        if parts.len() >= 3 {
680            Some(ChangedFile {
681                path: parts[2].to_string(),
682                old_path: Some(parts[1].to_string()),
683            })
684        } else {
685            None
686        }
687    } else if parts.len() >= 2 {
688        Some(ChangedFile {
689            path: parts[1].to_string(),
690            old_path: None,
691        })
692    } else {
693        None
694    }
695}
696
697/// Returns combined staged + unstaged diff for analysis without mutating the index.
698fn get_full_diff() -> anyhow::Result<String> {
699    let staged = git_output([
700        "diff",
701        "--cached",
702        "--no-color",
703        "--unified=0",
704        "--no-ext-diff",
705        "--find-renames",
706    ])?;
707    let unstaged = git_output([
708        "diff",
709        "--no-color",
710        "--unified=0",
711        "--no-ext-diff",
712        "--find-renames",
713    ])?;
714    Ok(format!("{staged}{unstaged}"))
715}
716
717#[cfg(test)]
718fn collect_changed_files() -> anyhow::Result<Vec<ChangedFile>> {
719    let name_status = git_output(["diff", "--cached", "--name-status", "--find-renames"])?;
720    let mut files = Vec::new();
721
722    for line in name_status.lines().map(str::trim).filter(|l| !l.is_empty()) {
723        let parts: Vec<&str> = line.split('\t').collect();
724        if parts.is_empty() {
725            continue;
726        }
727        let status = parts[0];
728        if status.starts_with('R') || status.starts_with('C') {
729            if parts.len() >= 3 {
730                files.push(ChangedFile {
731                    path: parts[2].to_string(),
732                    old_path: Some(parts[1].to_string()),
733                });
734            }
735        } else if parts.len() >= 2 {
736            files.push(ChangedFile {
737                path: parts[1].to_string(),
738                old_path: None,
739            });
740        }
741    }
742
743    Ok(files)
744}
745
746fn git_output<const N: usize>(args: [&str; N]) -> anyhow::Result<String> {
747    let out = Command::new("git").args(args).output_with_context()?;
748    if !out.status.success() {
749        anyhow::bail!("git {} failed", args.join(" "));
750    }
751    Ok(String::from_utf8_lossy(&out.stdout).to_string())
752}
753
754#[cfg(test)]
755mod tests {
756    use super::{build_plans, collect_changed_files, git_output, run};
757    use serial_test::serial;
758    use std::env;
759    use std::fs;
760    use std::process::Command;
761
762    fn init_temp_repo() -> tempfile::TempDir {
763        let dir = tempfile::tempdir().unwrap();
764        Command::new("git")
765            .arg("init")
766            .current_dir(dir.path())
767            .output()
768            .unwrap();
769        Command::new("git")
770            .args(["config", "user.email", "test@example.com"])
771            .current_dir(dir.path())
772            .output()
773            .unwrap();
774        Command::new("git")
775            .args(["config", "user.name", "Test User"])
776            .current_dir(dir.path())
777            .output()
778            .unwrap();
779        // The operator's global commit.gpgsign would call 1Password
780        Command::new("git")
781            .args(["config", "commit.gpgsign", "false"])
782            .current_dir(dir.path())
783            .output()
784            .unwrap();
785        fs::write(dir.path().join("README.md"), "hello\n").unwrap();
786        Command::new("git")
787            .args(["add", "."])
788            .current_dir(dir.path())
789            .output()
790            .unwrap();
791        Command::new("git")
792            .args(["commit", "-m", "init"])
793            .current_dir(dir.path())
794            .output()
795            .unwrap();
796        dir
797    }
798
799    #[test]
800    fn groups_docs_and_code_separately() {
801        let files = vec![
802            super::ChangedFile {
803                path: "README.md".to_string(),
804                old_path: None,
805            },
806            super::ChangedFile {
807                path: "src/main.rs".to_string(),
808                old_path: None,
809            },
810        ];
811
812        let plans = build_plans(files, "");
813        assert_eq!(plans.len(), 2);
814    }
815
816    #[tokio::test]
817    #[serial]
818    async fn dry_run_works_in_temp_repo() {
819        let dir = init_temp_repo();
820        env::set_current_dir(dir.path()).unwrap();
821        fs::write(dir.path().join("src.rs"), "fn main() {}\n").unwrap();
822
823        run(true, true, None, true).await.unwrap();
824
825        let log = git_output(["log", "--oneline", "-1"]).unwrap();
826        assert!(log.contains("init"));
827    }
828
829    #[tokio::test]
830    #[serial]
831    async fn commits_in_temp_repo_with_yes() {
832        let dir = init_temp_repo();
833        env::set_current_dir(dir.path()).unwrap();
834        fs::write(dir.path().join("notes.md"), "updated\n").unwrap();
835
836        run(false, true, None, true).await.unwrap();
837
838        let count = Command::new("git")
839            .args(["rev-list", "--count", "HEAD"])
840            .current_dir(dir.path())
841            .output()
842            .unwrap();
843        let count = String::from_utf8_lossy(&count.stdout)
844            .trim()
845            .parse::<u32>()
846            .unwrap();
847        assert!(count >= 2);
848    }
849
850    #[tokio::test]
851    #[serial]
852    async fn message_override_skips_llm_and_commits_once() {
853        let dir = init_temp_repo();
854        env::set_current_dir(dir.path()).unwrap();
855        fs::write(dir.path().join("a.txt"), "a\n").unwrap();
856        fs::write(dir.path().join("b.md"), "b\n").unwrap();
857
858        // no_llm=false on purpose — the override must take priority over the LLM path.
859        run(
860            false,
861            true,
862            Some("chore: forced override".to_string()),
863            false,
864        )
865        .await
866        .unwrap();
867
868        let log = git_output(["log", "--oneline", "-1"]).unwrap();
869        assert!(
870            log.contains("chore: forced override"),
871            "expected override message in HEAD, got: {log}"
872        );
873    }
874
875    #[test]
876    #[serial]
877    fn parses_name_status() {
878        let dir = init_temp_repo();
879        env::set_current_dir(dir.path()).unwrap();
880        fs::write(dir.path().join("x.txt"), "a\n").unwrap();
881        Command::new("git")
882            .args(["add", "-A"])
883            .current_dir(dir.path())
884            .output()
885            .unwrap();
886        let files = collect_changed_files().unwrap();
887        assert!(files.iter().any(|f| f.path == "x.txt"));
888    }
889
890    #[test]
891    fn clean_llm_message_strips_fences_and_whitespace() {
892        let raw = "```\nfeat(cli): add --no-llm flag\n\nLet users opt out of LLM-drafted messages.\n```\n";
893        let cleaned = super::clean_llm_message(raw);
894        assert_eq!(
895            cleaned,
896            "feat(cli): add --no-llm flag\n\nLet users opt out of LLM-drafted messages."
897        );
898    }
899
900    #[test]
901    fn clean_llm_message_strips_language_tagged_fence() {
902        let raw = "```text\nfix(router): handle empty pool\n```";
903        let cleaned = super::clean_llm_message(raw);
904        assert_eq!(cleaned, "fix(router): handle empty pool");
905    }
906
907    #[test]
908    fn clean_llm_message_passes_plain_text_through() {
909        let cleaned = super::clean_llm_message("  docs: clarify usage  \n\n");
910        assert_eq!(cleaned, "docs: clarify usage");
911    }
912
913    #[test]
914    fn truncate_diff_no_op_when_under_budget() {
915        let diff = "diff --git a/x b/x\n+hello\n";
916        let (out, truncated) = super::truncate_diff(diff, 1024);
917        assert!(!truncated);
918        assert_eq!(out, diff);
919    }
920
921    #[test]
922    fn truncate_diff_snaps_to_char_boundary() {
923        let diff = "héllo wörld"; // multi-byte chars in the middle
924        let (out, truncated) = super::truncate_diff(diff, 5);
925        assert!(truncated);
926        // out must be valid UTF-8 and never split a multi-byte char.
927        assert!(out.chars().count() > 0);
928        assert!(diff.starts_with(&out));
929    }
930
931    #[test]
932    #[serial]
933    fn diff_for_paths_includes_untracked_file_contents() {
934        let dir = init_temp_repo();
935        env::set_current_dir(dir.path()).unwrap();
936        // Brand-new file: never staged, never tracked. The LLM path must still
937        // see its contents — otherwise the message would be drafted from the
938        // filename alone, which is exactly the regression this guards against.
939        fs::write(dir.path().join("brand_new.txt"), "alpha\nbeta\ngamma\n").unwrap();
940
941        let diff = super::diff_for_paths(&["brand_new.txt".to_string()]).unwrap();
942        assert!(
943            diff.contains("brand_new.txt"),
944            "diff should reference the new file path, got: {diff}"
945        );
946        assert!(
947            diff.contains("+alpha") && diff.contains("+beta") && diff.contains("+gamma"),
948            "diff should include the new file's added lines, got: {diff}"
949        );
950    }
951}