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