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(&plans)?;
73                return Ok(());
74            }
75            UserChoice::Yes => {}
76        }
77    }
78
79    execute_plans(&plans)
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
309fn execute_plans(plans: &[CommitPlan]) -> anyhow::Result<()> {
310    git_reset_unstage_all()?;
311
312    for plan in plans {
313        let mut path_set: BTreeSet<String> = BTreeSet::new();
314        for file in &plan.files {
315            if let Some(old) = &file.old_path {
316                path_set.insert(old.clone());
317            }
318            path_set.insert(file.path.clone());
319        }
320
321        let mut cmd = std::process::Command::new("git");
322        cmd.arg("add").arg("-A").arg("--");
323        for path in path_set {
324            cmd.arg(path);
325        }
326        let add = cmd.output_with_context()?;
327        if !add.status.success() {
328            anyhow::bail!(
329                "failed to stage commit group: {}",
330                String::from_utf8_lossy(&add.stderr).trim()
331            );
332        }
333
334        let commit = std::process::Command::new("git")
335            .args(["commit", "-m", &plan.message])
336            .output_with_context()
337            .context("failed to run git commit")?;
338        if !commit.status.success() {
339            anyhow::bail!(
340                "git commit failed: {}",
341                String::from_utf8_lossy(&commit.stderr).trim()
342            );
343        }
344    }
345
346    Ok(())
347}
348
349enum UserChoice {
350    Yes,
351    No,
352    Edit,
353}
354
355fn prompt_confirmation() -> anyhow::Result<UserChoice> {
356    print!("\nCommit these changes? [y/n/e] ");
357    io::stdout().flush().ok();
358    let mut input = String::new();
359    io::stdin().read_line(&mut input)?;
360    let value = input.trim().to_ascii_lowercase();
361    Ok(match value.as_str() {
362        "y" | "yes" => UserChoice::Yes,
363        "e" | "edit" => UserChoice::Edit,
364        _ => UserChoice::No,
365    })
366}
367
368fn edit_messages(plans: &mut [CommitPlan]) -> anyhow::Result<()> {
369    println!("\nEdit commit messages (press enter to keep current message):");
370    for (i, plan) in plans.iter_mut().enumerate() {
371        println!("\n{}. {}", i + 1, plan.message);
372        print!("new message> ");
373        io::stdout().flush().ok();
374        let mut input = String::new();
375        io::stdin().read_line(&mut input)?;
376        let next = input.trim();
377        if !next.is_empty() {
378            plan.message = next.to_string();
379        }
380    }
381    Ok(())
382}
383
384fn print_plan_summary(plans: &[CommitPlan]) {
385    let file_count: usize = plans.iter().map(|p| p.files.len()).sum();
386    println!(
387        "\nProposed {} commits from {} changed files:\n",
388        plans.len(),
389        file_count
390    );
391    for (i, plan) in plans.iter().enumerate() {
392        println!(
393            "{}. {}",
394            i + 1,
395            plan.message.lines().next().unwrap_or("commit")
396        );
397        for file in &plan.files {
398            if let Some(old) = &file.old_path {
399                println!("   - {} -> {}", old, file.path);
400            } else {
401                println!("   - {}", file.path);
402            }
403        }
404        if plan.message.lines().count() > 1 {
405            for body_line in plan.message.lines().skip(1) {
406                println!("   {}", body_line);
407            }
408        }
409        println!();
410    }
411}
412
413fn build_plans(files: Vec<ChangedFile>, diff_text: &str) -> Vec<CommitPlan> {
414    let hunks = parse_unified_diff_hunks(diff_text);
415    let mut hunk_counts: BTreeMap<&str, usize> = BTreeMap::new();
416    for file in &hunks {
417        hunk_counts.insert(file.path.as_str(), file.hunks.len());
418    }
419
420    let mut groups: BTreeMap<String, Vec<ChangedFile>> = BTreeMap::new();
421
422    for file in files {
423        let key = group_key(&file.path);
424        groups.entry(key).or_default().push(file);
425    }
426
427    let mut plans = Vec::new();
428    for (_, mut grouped_files) in groups {
429        grouped_files.sort_by(|a, b| a.path.cmp(&b.path));
430        let message = message_for_group(&grouped_files, &hunk_counts);
431        plans.push(CommitPlan {
432            message,
433            files: grouped_files,
434        });
435    }
436
437    plans.sort_by(|a, b| a.message.cmp(&b.message));
438    plans
439}
440
441fn group_key(path: &str) -> String {
442    if is_doc(path) {
443        return "docs".to_string();
444    }
445    if is_config(path) {
446        return "config".to_string();
447    }
448
449    if is_test(path) {
450        if let Some(module) = test_module(path) {
451            return format!("test:{module}");
452        }
453        return "test".to_string();
454    }
455
456    let dir = Path::new(path)
457        .parent()
458        .and_then(|p| p.to_str())
459        .unwrap_or("")
460        .to_string();
461
462    if dir.is_empty() {
463        "root".to_string()
464    } else {
465        let parts: Vec<&str> = dir.split('/').collect();
466        if parts.first().copied() == Some("src") && parts.len() > 1 {
467            format!("src:{}", parts[1])
468        } else {
469            format!("dir:{dir}")
470        }
471    }
472}
473
474fn message_for_group(files: &[ChangedFile], hunk_counts: &BTreeMap<&str, usize>) -> String {
475    let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
476
477    let kind = if paths.iter().all(|p| is_doc(p)) {
478        "docs"
479    } else if paths.iter().all(|p| is_test(p)) {
480        "test"
481    } else if paths.iter().all(|p| is_config(p)) {
482        "chore"
483    } else if files.iter().any(|f| f.old_path.is_some()) {
484        "refactor"
485    } else {
486        "feat"
487    };
488
489    let scope = infer_scope(&paths);
490    let subject = if files.len() == 1 {
491        format!("update {}", paths[0])
492    } else {
493        format!("update {} files", scope.as_deref().unwrap_or("project"))
494    };
495
496    let header = if let Some(scope) = &scope {
497        format!("{kind}({scope}): {subject}")
498    } else {
499        format!("{kind}: {subject}")
500    };
501
502    let total_hunks: usize = files
503        .iter()
504        .map(|f| hunk_counts.get(f.path.as_str()).copied().unwrap_or(0))
505        .sum();
506
507    if total_hunks > 3 || files.len() > 2 {
508        let mut body = String::new();
509        body.push_str("\n\nIncludes:\n");
510        for file in files {
511            body.push_str("- ");
512            body.push_str(&file.path);
513            body.push('\n');
514        }
515        format!("{header}{body}")
516    } else {
517        header
518    }
519}
520
521fn infer_scope(paths: &[&str]) -> Option<String> {
522    let first = paths.first()?;
523    let parent = Path::new(first)
524        .parent()
525        .and_then(|p| p.file_name())
526        .and_then(|n| n.to_str())
527        .map(|s| s.to_string());
528
529    if paths.iter().all(|p| {
530        Path::new(p)
531            .parent()
532            .and_then(|pp| pp.file_name())
533            .and_then(|n| n.to_str())
534            .map(|s| Some(s.to_string()) == parent)
535            .unwrap_or(false)
536    }) {
537        parent
538    } else {
539        None
540    }
541}
542
543fn is_doc(path: &str) -> bool {
544    path.ends_with(".md") || path.starts_with("docs/")
545}
546
547fn is_test(path: &str) -> bool {
548    path.starts_with("tests/") || path.contains("_test.") || path.ends_with(".spec.ts")
549}
550
551fn test_module(path: &str) -> Option<String> {
552    let stem = Path::new(path).file_stem()?.to_str()?;
553    Some(stem.trim_end_matches("_test").to_string())
554}
555
556fn is_config(path: &str) -> bool {
557    matches!(
558        path,
559        "Cargo.toml"
560            | "Cargo.lock"
561            | "package.json"
562            | "bun.lockb"
563            | "bun.lock"
564            | "pnpm-lock.yaml"
565            | "package-lock.json"
566    ) || path.starts_with(".github/workflows/")
567}
568
569fn ensure_git_repo() -> anyhow::Result<()> {
570    let out = std::process::Command::new("git")
571        .args(["rev-parse", "--is-inside-work-tree"])
572        .output_with_context()?;
573    if !out.status.success() {
574        anyhow::bail!("not a git repository");
575    }
576    Ok(())
577}
578
579fn ensure_no_merge_conflicts() -> anyhow::Result<()> {
580    let out = git_output(["diff", "--name-only", "--diff-filter=U"])?;
581    if !out.trim().is_empty() {
582        anyhow::bail!("merge conflicts detected; resolve conflicts before committing");
583    }
584    Ok(())
585}
586
587fn has_changes() -> anyhow::Result<bool> {
588    let unstaged = std::process::Command::new("git")
589        .args(["diff", "--quiet"])
590        .status_with_context()?;
591    let staged = std::process::Command::new("git")
592        .args(["diff", "--cached", "--quiet"])
593        .status_with_context()?;
594    let untracked = git_output(["ls-files", "--others", "--exclude-standard"])?;
595
596    Ok(!unstaged.success() || !staged.success() || !untracked.trim().is_empty())
597}
598
599fn git_reset_unstage_all() -> anyhow::Result<()> {
600    let out = std::process::Command::new("git")
601        .args(["reset"])
602        .output_with_context()?;
603    if !out.status.success() {
604        anyhow::bail!("git reset failed: {}", String::from_utf8_lossy(&out.stderr));
605    }
606    Ok(())
607}
608
609/// Collect all changed files (staged + unstaged + untracked) without touching the index.
610fn collect_all_changed_files() -> anyhow::Result<Vec<ChangedFile>> {
611    let mut seen = BTreeSet::new();
612    let mut files = Vec::new();
613
614    let staged = git_output(["diff", "--cached", "--name-status", "--find-renames"])?;
615    for line in staged.lines().map(str::trim).filter(|l| !l.is_empty()) {
616        if let Some(cf) = parse_name_status_line(line) {
617            if seen.insert(cf.path.clone()) {
618                files.push(cf);
619            }
620        }
621    }
622
623    let unstaged = git_output(["diff", "--name-status", "--find-renames"])?;
624    for line in unstaged.lines().map(str::trim).filter(|l| !l.is_empty()) {
625        if let Some(cf) = parse_name_status_line(line) {
626            if seen.insert(cf.path.clone()) {
627                files.push(cf);
628            }
629        }
630    }
631
632    let untracked = git_output(["ls-files", "--others", "--exclude-standard"])?;
633    for line in untracked.lines().map(str::trim).filter(|l| !l.is_empty()) {
634        if seen.insert(line.to_string()) {
635            files.push(ChangedFile {
636                path: line.to_string(),
637                old_path: None,
638            });
639        }
640    }
641
642    Ok(files)
643}
644
645fn parse_name_status_line(line: &str) -> Option<ChangedFile> {
646    let parts: Vec<&str> = line.split('\t').collect();
647    let status = parts.first()?;
648    if status.starts_with('R') || status.starts_with('C') {
649        if parts.len() >= 3 {
650            Some(ChangedFile {
651                path: parts[2].to_string(),
652                old_path: Some(parts[1].to_string()),
653            })
654        } else {
655            None
656        }
657    } else if parts.len() >= 2 {
658        Some(ChangedFile {
659            path: parts[1].to_string(),
660            old_path: None,
661        })
662    } else {
663        None
664    }
665}
666
667/// Returns combined staged + unstaged diff for analysis without mutating the index.
668fn get_full_diff() -> anyhow::Result<String> {
669    let staged = git_output([
670        "diff",
671        "--cached",
672        "--no-color",
673        "--unified=0",
674        "--no-ext-diff",
675        "--find-renames",
676    ])?;
677    let unstaged = git_output([
678        "diff",
679        "--no-color",
680        "--unified=0",
681        "--no-ext-diff",
682        "--find-renames",
683    ])?;
684    Ok(format!("{staged}{unstaged}"))
685}
686
687#[cfg(test)]
688fn collect_changed_files() -> anyhow::Result<Vec<ChangedFile>> {
689    let name_status = git_output(["diff", "--cached", "--name-status", "--find-renames"])?;
690    let mut files = Vec::new();
691
692    for line in name_status.lines().map(str::trim).filter(|l| !l.is_empty()) {
693        let parts: Vec<&str> = line.split('\t').collect();
694        if parts.is_empty() {
695            continue;
696        }
697        let status = parts[0];
698        if status.starts_with('R') || status.starts_with('C') {
699            if parts.len() >= 3 {
700                files.push(ChangedFile {
701                    path: parts[2].to_string(),
702                    old_path: Some(parts[1].to_string()),
703                });
704            }
705        } else if parts.len() >= 2 {
706            files.push(ChangedFile {
707                path: parts[1].to_string(),
708                old_path: None,
709            });
710        }
711    }
712
713    Ok(files)
714}
715
716fn git_output<const N: usize>(args: [&str; N]) -> anyhow::Result<String> {
717    let out = std::process::Command::new("git")
718        .args(args)
719        .output_with_context()?;
720    if !out.status.success() {
721        anyhow::bail!("git {} failed", args.join(" "));
722    }
723    Ok(String::from_utf8_lossy(&out.stdout).to_string())
724}
725
726#[cfg(test)]
727mod tests {
728    use super::{build_plans, collect_changed_files, git_output, run};
729    use serial_test::serial;
730    use std::process::Command;
731
732    fn init_temp_repo() -> tempfile::TempDir {
733        let dir = tempfile::tempdir().unwrap();
734        Command::new("git")
735            .arg("init")
736            .current_dir(dir.path())
737            .output()
738            .unwrap();
739        Command::new("git")
740            .args(["config", "user.email", "test@example.com"])
741            .current_dir(dir.path())
742            .output()
743            .unwrap();
744        Command::new("git")
745            .args(["config", "user.name", "Test User"])
746            .current_dir(dir.path())
747            .output()
748            .unwrap();
749        // The operator's global commit.gpgsign would call 1Password
750        Command::new("git")
751            .args(["config", "commit.gpgsign", "false"])
752            .current_dir(dir.path())
753            .output()
754            .unwrap();
755        std::fs::write(dir.path().join("README.md"), "hello\n").unwrap();
756        Command::new("git")
757            .args(["add", "."])
758            .current_dir(dir.path())
759            .output()
760            .unwrap();
761        Command::new("git")
762            .args(["commit", "-m", "init"])
763            .current_dir(dir.path())
764            .output()
765            .unwrap();
766        dir
767    }
768
769    #[test]
770    fn groups_docs_and_code_separately() {
771        let files = vec![
772            super::ChangedFile {
773                path: "README.md".to_string(),
774                old_path: None,
775            },
776            super::ChangedFile {
777                path: "src/main.rs".to_string(),
778                old_path: None,
779            },
780        ];
781
782        let plans = build_plans(files, "");
783        assert_eq!(plans.len(), 2);
784    }
785
786    #[tokio::test]
787    #[serial]
788    async fn dry_run_works_in_temp_repo() {
789        let dir = init_temp_repo();
790        std::env::set_current_dir(dir.path()).unwrap();
791        std::fs::write(dir.path().join("src.rs"), "fn main() {}\n").unwrap();
792
793        run(true, true, None, true).await.unwrap();
794
795        let log = git_output(["log", "--oneline", "-1"]).unwrap();
796        assert!(log.contains("init"));
797    }
798
799    #[tokio::test]
800    #[serial]
801    async fn commits_in_temp_repo_with_yes() {
802        let dir = init_temp_repo();
803        std::env::set_current_dir(dir.path()).unwrap();
804        std::fs::write(dir.path().join("notes.md"), "updated\n").unwrap();
805
806        run(false, true, None, true).await.unwrap();
807
808        let count = Command::new("git")
809            .args(["rev-list", "--count", "HEAD"])
810            .current_dir(dir.path())
811            .output()
812            .unwrap();
813        let count = String::from_utf8_lossy(&count.stdout)
814            .trim()
815            .parse::<u32>()
816            .unwrap();
817        assert!(count >= 2);
818    }
819
820    #[tokio::test]
821    #[serial]
822    async fn message_override_skips_llm_and_commits_once() {
823        let dir = init_temp_repo();
824        std::env::set_current_dir(dir.path()).unwrap();
825        std::fs::write(dir.path().join("a.txt"), "a\n").unwrap();
826        std::fs::write(dir.path().join("b.md"), "b\n").unwrap();
827
828        // no_llm=false on purpose — the override must take priority over the LLM path.
829        run(
830            false,
831            true,
832            Some("chore: forced override".to_string()),
833            false,
834        )
835        .await
836        .unwrap();
837
838        let log = git_output(["log", "--oneline", "-1"]).unwrap();
839        assert!(
840            log.contains("chore: forced override"),
841            "expected override message in HEAD, got: {log}"
842        );
843    }
844
845    #[test]
846    #[serial]
847    fn parses_name_status() {
848        let dir = init_temp_repo();
849        std::env::set_current_dir(dir.path()).unwrap();
850        std::fs::write(dir.path().join("x.txt"), "a\n").unwrap();
851        Command::new("git")
852            .args(["add", "-A"])
853            .current_dir(dir.path())
854            .output()
855            .unwrap();
856        let files = collect_changed_files().unwrap();
857        assert!(files.iter().any(|f| f.path == "x.txt"));
858    }
859
860    #[test]
861    fn clean_llm_message_strips_fences_and_whitespace() {
862        let raw = "```\nfeat(cli): add --no-llm flag\n\nLet users opt out of LLM-drafted messages.\n```\n";
863        let cleaned = super::clean_llm_message(raw);
864        assert_eq!(
865            cleaned,
866            "feat(cli): add --no-llm flag\n\nLet users opt out of LLM-drafted messages."
867        );
868    }
869
870    #[test]
871    fn clean_llm_message_strips_language_tagged_fence() {
872        let raw = "```text\nfix(router): handle empty pool\n```";
873        let cleaned = super::clean_llm_message(raw);
874        assert_eq!(cleaned, "fix(router): handle empty pool");
875    }
876
877    #[test]
878    fn clean_llm_message_passes_plain_text_through() {
879        let cleaned = super::clean_llm_message("  docs: clarify usage  \n\n");
880        assert_eq!(cleaned, "docs: clarify usage");
881    }
882
883    #[test]
884    fn truncate_diff_no_op_when_under_budget() {
885        let diff = "diff --git a/x b/x\n+hello\n";
886        let (out, truncated) = super::truncate_diff(diff, 1024);
887        assert!(!truncated);
888        assert_eq!(out, diff);
889    }
890
891    #[test]
892    fn truncate_diff_snaps_to_char_boundary() {
893        let diff = "héllo wörld"; // multi-byte chars in the middle
894        let (out, truncated) = super::truncate_diff(diff, 5);
895        assert!(truncated);
896        // out must be valid UTF-8 and never split a multi-byte char.
897        assert!(out.chars().count() > 0);
898        assert!(diff.starts_with(&out));
899    }
900
901    #[test]
902    #[serial]
903    fn diff_for_paths_includes_untracked_file_contents() {
904        let dir = init_temp_repo();
905        std::env::set_current_dir(dir.path()).unwrap();
906        // Brand-new file: never staged, never tracked. The LLM path must still
907        // see its contents — otherwise the message would be drafted from the
908        // filename alone, which is exactly the regression this guards against.
909        std::fs::write(dir.path().join("brand_new.txt"), "alpha\nbeta\ngamma\n").unwrap();
910
911        let diff = super::diff_for_paths(&["brand_new.txt".to_string()]).unwrap();
912        assert!(
913            diff.contains("brand_new.txt"),
914            "diff should reference the new file path, got: {diff}"
915        );
916        assert!(
917            diff.contains("+alpha") && diff.contains("+beta") && diff.contains("+gamma"),
918            "diff should include the new file's added lines, got: {diff}"
919        );
920    }
921}