Skip to main content

orch/cli/
commit.rs

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