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).await?;
73 return Ok(());
74 }
75 UserChoice::Yes => {}
76 }
77 }
78
79 execute(&plans).await
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 | 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
309async 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(std::string::ToString::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 .is_some_and(|s| Some(s.to_string()) == parent)
561 }) {
562 parent
563 } else {
564 None
565 }
566}
567
568fn is_doc(path: &str) -> bool {
569 std::path::Path::new(path)
570 .extension()
571 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
572 || path.starts_with("docs/")
573}
574
575fn is_test(path: &str) -> bool {
576 path.starts_with("tests/") || path.contains("_test.") || path.ends_with(".spec.ts")
577}
578
579fn test_module(path: &str) -> Option<String> {
580 let stem = Path::new(path).file_stem()?.to_str()?;
581 Some(stem.trim_end_matches("_test").to_string())
582}
583
584fn is_config(path: &str) -> bool {
585 matches!(
586 path,
587 "Cargo.toml"
588 | "Cargo.lock"
589 | "package.json"
590 | "bun.lockb"
591 | "bun.lock"
592 | "pnpm-lock.yaml"
593 | "package-lock.json"
594 ) || path.starts_with(".github/workflows/")
595}
596
597fn ensure_git_repo() -> anyhow::Result<()> {
598 let out = std::process::Command::new("git")
599 .args(["rev-parse", "--is-inside-work-tree"])
600 .output_with_context()?;
601 if !out.status.success() {
602 anyhow::bail!("not a git repository");
603 }
604 Ok(())
605}
606
607fn ensure_no_merge_conflicts() -> anyhow::Result<()> {
608 let out = git_output(["diff", "--name-only", "--diff-filter=U"])?;
609 if !out.trim().is_empty() {
610 anyhow::bail!("merge conflicts detected; resolve conflicts before committing");
611 }
612 Ok(())
613}
614
615fn has_changes() -> anyhow::Result<bool> {
616 let unstaged = std::process::Command::new("git")
617 .args(["diff", "--quiet"])
618 .status_with_context()?;
619 let staged = std::process::Command::new("git")
620 .args(["diff", "--cached", "--quiet"])
621 .status_with_context()?;
622 let untracked = git_output(["ls-files", "--others", "--exclude-standard"])?;
623
624 Ok(!unstaged.success() || !staged.success() || !untracked.trim().is_empty())
625}
626
627fn git_reset_unstage_all() -> anyhow::Result<()> {
628 let out = std::process::Command::new("git")
629 .args(["reset"])
630 .output_with_context()?;
631 if !out.status.success() {
632 anyhow::bail!("git reset failed: {}", String::from_utf8_lossy(&out.stderr));
633 }
634 Ok(())
635}
636
637fn collect_all_changed_files() -> anyhow::Result<Vec<ChangedFile>> {
639 let mut seen = BTreeSet::new();
640 let mut files = Vec::new();
641
642 let staged = git_output(["diff", "--cached", "--name-status", "--find-renames"])?;
643 for line in staged.lines().map(str::trim).filter(|l| !l.is_empty()) {
644 if let Some(cf) = parse_name_status_line(line)
645 && seen.insert(cf.path.clone())
646 {
647 files.push(cf);
648 }
649 }
650
651 let unstaged = git_output(["diff", "--name-status", "--find-renames"])?;
652 for line in unstaged.lines().map(str::trim).filter(|l| !l.is_empty()) {
653 if let Some(cf) = parse_name_status_line(line)
654 && seen.insert(cf.path.clone())
655 {
656 files.push(cf);
657 }
658 }
659
660 let untracked = git_output(["ls-files", "--others", "--exclude-standard"])?;
661 for line in untracked.lines().map(str::trim).filter(|l| !l.is_empty()) {
662 if seen.insert(line.to_string()) {
663 files.push(ChangedFile {
664 path: line.to_string(),
665 old_path: None,
666 });
667 }
668 }
669
670 Ok(files)
671}
672
673fn parse_name_status_line(line: &str) -> Option<ChangedFile> {
674 let parts: Vec<&str> = line.split('\t').collect();
675 let status = parts.first()?;
676 if status.starts_with('R') || status.starts_with('C') {
677 if parts.len() >= 3 {
678 Some(ChangedFile {
679 path: parts[2].to_string(),
680 old_path: Some(parts[1].to_string()),
681 })
682 } else {
683 None
684 }
685 } else if parts.len() >= 2 {
686 Some(ChangedFile {
687 path: parts[1].to_string(),
688 old_path: None,
689 })
690 } else {
691 None
692 }
693}
694
695fn get_full_diff() -> anyhow::Result<String> {
697 let staged = git_output([
698 "diff",
699 "--cached",
700 "--no-color",
701 "--unified=0",
702 "--no-ext-diff",
703 "--find-renames",
704 ])?;
705 let unstaged = git_output([
706 "diff",
707 "--no-color",
708 "--unified=0",
709 "--no-ext-diff",
710 "--find-renames",
711 ])?;
712 Ok(format!("{staged}{unstaged}"))
713}
714
715#[cfg(test)]
716fn collect_changed_files() -> anyhow::Result<Vec<ChangedFile>> {
717 let name_status = git_output(["diff", "--cached", "--name-status", "--find-renames"])?;
718 let mut files = Vec::new();
719
720 for line in name_status.lines().map(str::trim).filter(|l| !l.is_empty()) {
721 let parts: Vec<&str> = line.split('\t').collect();
722 if parts.is_empty() {
723 continue;
724 }
725 let status = parts[0];
726 if status.starts_with('R') || status.starts_with('C') {
727 if parts.len() >= 3 {
728 files.push(ChangedFile {
729 path: parts[2].to_string(),
730 old_path: Some(parts[1].to_string()),
731 });
732 }
733 } else if parts.len() >= 2 {
734 files.push(ChangedFile {
735 path: parts[1].to_string(),
736 old_path: None,
737 });
738 }
739 }
740
741 Ok(files)
742}
743
744fn git_output<const N: usize>(args: [&str; N]) -> anyhow::Result<String> {
745 let out = std::process::Command::new("git")
746 .args(args)
747 .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::process::Command;
759
760 fn init_temp_repo() -> tempfile::TempDir {
761 let dir = tempfile::tempdir().unwrap();
762 Command::new("git")
763 .arg("init")
764 .current_dir(dir.path())
765 .output()
766 .unwrap();
767 Command::new("git")
768 .args(["config", "user.email", "test@example.com"])
769 .current_dir(dir.path())
770 .output()
771 .unwrap();
772 Command::new("git")
773 .args(["config", "user.name", "Test User"])
774 .current_dir(dir.path())
775 .output()
776 .unwrap();
777 Command::new("git")
779 .args(["config", "commit.gpgsign", "false"])
780 .current_dir(dir.path())
781 .output()
782 .unwrap();
783 std::fs::write(dir.path().join("README.md"), "hello\n").unwrap();
784 Command::new("git")
785 .args(["add", "."])
786 .current_dir(dir.path())
787 .output()
788 .unwrap();
789 Command::new("git")
790 .args(["commit", "-m", "init"])
791 .current_dir(dir.path())
792 .output()
793 .unwrap();
794 dir
795 }
796
797 #[test]
798 fn groups_docs_and_code_separately() {
799 let files = vec![
800 super::ChangedFile {
801 path: "README.md".to_string(),
802 old_path: None,
803 },
804 super::ChangedFile {
805 path: "src/main.rs".to_string(),
806 old_path: None,
807 },
808 ];
809
810 let plans = build_plans(files, "");
811 assert_eq!(plans.len(), 2);
812 }
813
814 #[tokio::test]
815 #[serial]
816 async fn dry_run_works_in_temp_repo() {
817 let dir = init_temp_repo();
818 std::env::set_current_dir(dir.path()).unwrap();
819 std::fs::write(dir.path().join("src.rs"), "fn main() {}\n").unwrap();
820
821 run(true, true, None, true).await.unwrap();
822
823 let log = git_output(["log", "--oneline", "-1"]).unwrap();
824 assert!(log.contains("init"));
825 }
826
827 #[tokio::test]
828 #[serial]
829 async fn commits_in_temp_repo_with_yes() {
830 let dir = init_temp_repo();
831 std::env::set_current_dir(dir.path()).unwrap();
832 std::fs::write(dir.path().join("notes.md"), "updated\n").unwrap();
833
834 run(false, true, None, true).await.unwrap();
835
836 let count = Command::new("git")
837 .args(["rev-list", "--count", "HEAD"])
838 .current_dir(dir.path())
839 .output()
840 .unwrap();
841 let count = String::from_utf8_lossy(&count.stdout)
842 .trim()
843 .parse::<u32>()
844 .unwrap();
845 assert!(count >= 2);
846 }
847
848 #[tokio::test]
849 #[serial]
850 async fn message_override_skips_llm_and_commits_once() {
851 let dir = init_temp_repo();
852 std::env::set_current_dir(dir.path()).unwrap();
853 std::fs::write(dir.path().join("a.txt"), "a\n").unwrap();
854 std::fs::write(dir.path().join("b.md"), "b\n").unwrap();
855
856 run(
858 false,
859 true,
860 Some("chore: forced override".to_string()),
861 false,
862 )
863 .await
864 .unwrap();
865
866 let log = git_output(["log", "--oneline", "-1"]).unwrap();
867 assert!(
868 log.contains("chore: forced override"),
869 "expected override message in HEAD, got: {log}"
870 );
871 }
872
873 #[test]
874 #[serial]
875 fn parses_name_status() {
876 let dir = init_temp_repo();
877 std::env::set_current_dir(dir.path()).unwrap();
878 std::fs::write(dir.path().join("x.txt"), "a\n").unwrap();
879 Command::new("git")
880 .args(["add", "-A"])
881 .current_dir(dir.path())
882 .output()
883 .unwrap();
884 let files = collect_changed_files().unwrap();
885 assert!(files.iter().any(|f| f.path == "x.txt"));
886 }
887
888 #[test]
889 fn clean_llm_message_strips_fences_and_whitespace() {
890 let raw = "```\nfeat(cli): add --no-llm flag\n\nLet users opt out of LLM-drafted messages.\n```\n";
891 let cleaned = super::clean_llm_message(raw);
892 assert_eq!(
893 cleaned,
894 "feat(cli): add --no-llm flag\n\nLet users opt out of LLM-drafted messages."
895 );
896 }
897
898 #[test]
899 fn clean_llm_message_strips_language_tagged_fence() {
900 let raw = "```text\nfix(router): handle empty pool\n```";
901 let cleaned = super::clean_llm_message(raw);
902 assert_eq!(cleaned, "fix(router): handle empty pool");
903 }
904
905 #[test]
906 fn clean_llm_message_passes_plain_text_through() {
907 let cleaned = super::clean_llm_message(" docs: clarify usage \n\n");
908 assert_eq!(cleaned, "docs: clarify usage");
909 }
910
911 #[test]
912 fn truncate_diff_no_op_when_under_budget() {
913 let diff = "diff --git a/x b/x\n+hello\n";
914 let (out, truncated) = super::truncate_diff(diff, 1024);
915 assert!(!truncated);
916 assert_eq!(out, diff);
917 }
918
919 #[test]
920 fn truncate_diff_snaps_to_char_boundary() {
921 let diff = "héllo wörld"; let (out, truncated) = super::truncate_diff(diff, 5);
923 assert!(truncated);
924 assert!(out.chars().count() > 0);
926 assert!(diff.starts_with(&out));
927 }
928
929 #[test]
930 #[serial]
931 fn diff_for_paths_includes_untracked_file_contents() {
932 let dir = init_temp_repo();
933 std::env::set_current_dir(dir.path()).unwrap();
934 std::fs::write(dir.path().join("brand_new.txt"), "alpha\nbeta\ngamma\n").unwrap();
938
939 let diff = super::diff_for_paths(&["brand_new.txt".to_string()]).unwrap();
940 assert!(
941 diff.contains("brand_new.txt"),
942 "diff should reference the new file path, got: {diff}"
943 );
944 assert!(
945 diff.contains("+alpha") && diff.contains("+beta") && diff.contains("+gamma"),
946 "diff should include the new file's added lines, got: {diff}"
947 );
948 }
949}