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) | 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
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(|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
635fn 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
693fn 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 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 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"; let (out, truncated) = super::truncate_diff(diff, 5);
921 assert!(truncated);
922 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 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}