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
8const 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 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 return Ok(());
61 }
62
63 if !yes {
64 match prompt_confirmation()? {
65 UserChoice::No => {
66 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
82async 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
136async 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
184fn clean_llm_message(raw: &str) -> String {
186 let trimmed = raw.trim();
187
188 let without_open_fence = if let Some(rest) = trimmed.strip_prefix("```") {
190 match rest.find('\n') {
192 Some(i) => &rest[i + 1..],
193 None => rest,
194 }
195 } else {
196 trimmed
197 };
198
199 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
208fn 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
220fn 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
271fn 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
609fn 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
667fn 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 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 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"; let (out, truncated) = super::truncate_diff(diff, 5);
895 assert!(truncated);
896 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 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}