1use std::collections::{HashMap, HashSet};
7use std::path::Path;
8use std::process::Command;
9
10use anyhow::{Context, Result};
11
12use super::config::AutoMergePolicy;
13
14const OVERRIDES_FILE: &str = ".batty/auto_merge_overrides.json";
15
16pub fn load_overrides(project_root: &Path) -> HashMap<u32, bool> {
18 let path = project_root.join(OVERRIDES_FILE);
19 let Ok(content) = std::fs::read_to_string(&path) else {
20 return HashMap::new();
21 };
22 serde_json::from_str(&content).unwrap_or_default()
23}
24
25pub fn save_override(project_root: &Path, task_id: u32, enabled: bool) -> Result<()> {
27 let path = project_root.join(OVERRIDES_FILE);
28 let mut overrides = load_overrides(project_root);
29 overrides.insert(task_id, enabled);
30 let content = serde_json::to_string_pretty(&overrides)
31 .context("failed to serialize auto-merge overrides")?;
32 if let Some(parent) = path.parent() {
33 std::fs::create_dir_all(parent).ok();
34 }
35 std::fs::write(&path, content).context("failed to write auto-merge overrides file")?;
36 Ok(())
37}
38
39#[derive(Debug, Clone)]
41pub struct DiffSummary {
42 pub files_changed: usize,
43 pub lines_added: usize,
44 pub lines_removed: usize,
45 pub modules_touched: HashSet<String>,
46 pub sensitive_files: Vec<String>,
47 pub has_unsafe: bool,
48 pub has_conflicts: bool,
49 pub rename_count: usize,
51 pub has_migrations: bool,
53 pub has_config_changes: bool,
55}
56
57impl DiffSummary {
58 pub fn total_lines(&self) -> usize {
59 self.lines_added + self.lines_removed
60 }
61}
62
63#[derive(Debug, Clone, PartialEq)]
65pub enum AutoMergeDecision {
66 AutoMerge {
67 confidence: f64,
68 },
69 ManualReview {
70 confidence: f64,
71 reasons: Vec<String>,
72 },
73}
74
75pub fn analyze_diff(repo: &Path, base: &str, branch: &str) -> Result<DiffSummary> {
77 let stat_output = Command::new("git")
79 .args(["diff", "--numstat", &format!("{}...{}", base, branch)])
80 .current_dir(repo)
81 .output()
82 .context("failed to run git diff --numstat")?;
83
84 let stat_str = String::from_utf8_lossy(&stat_output.stdout);
85
86 let mut files_changed = 0usize;
87 let mut lines_added = 0usize;
88 let mut lines_removed = 0usize;
89 let mut modules_touched = HashSet::new();
90 let mut changed_paths = Vec::new();
91
92 for line in stat_str.lines() {
93 let parts: Vec<&str> = line.split('\t').collect();
94 if parts.len() < 3 {
95 continue;
96 }
97 files_changed += 1;
98 if let Ok(added) = parts[0].parse::<usize>() {
99 lines_added += added;
100 }
101 if let Ok(removed) = parts[1].parse::<usize>() {
102 lines_removed += removed;
103 }
104 let path = parts[2];
105 changed_paths.push(path.to_string());
106
107 if let Some(rest) = path.strip_prefix("src/") {
109 if let Some(module) = rest.split('/').next() {
110 modules_touched.insert(module.to_string());
111 }
112 }
113 }
114
115 let diff_output = Command::new("git")
117 .args(["diff", &format!("{}...{}", base, branch)])
118 .current_dir(repo)
119 .output()
120 .context("failed to run git diff")?;
121
122 let diff_str = String::from_utf8_lossy(&diff_output.stdout);
123 let has_unsafe = diff_str.lines().any(|line| {
124 line.starts_with('+') && (line.contains("unsafe {") || line.contains("unsafe fn"))
125 });
126
127 let rename_output = Command::new("git")
129 .args([
130 "diff",
131 "--diff-filter=R",
132 "--name-only",
133 &format!("{}...{}", base, branch),
134 ])
135 .current_dir(repo)
136 .output()
137 .context("failed to run git diff --diff-filter=R")?;
138 let rename_count = String::from_utf8_lossy(&rename_output.stdout)
139 .lines()
140 .filter(|l| !l.is_empty())
141 .count();
142
143 let has_migrations = changed_paths.iter().any(|p| is_migration_file(p));
145 let has_config_changes = changed_paths.iter().any(|p| is_config_file(p));
146
147 let has_conflicts = check_has_conflicts(repo, base, branch);
149
150 Ok(DiffSummary {
151 files_changed,
152 lines_added,
153 lines_removed,
154 modules_touched,
155 sensitive_files: changed_paths, has_unsafe,
157 has_conflicts,
158 rename_count,
159 has_migrations,
160 has_config_changes,
161 })
162}
163
164fn check_has_conflicts(repo: &Path, base: &str, branch: &str) -> bool {
166 let merge_base = Command::new("git")
167 .args(["merge-base", base, branch])
168 .current_dir(repo)
169 .output();
170 let merge_base_sha = match merge_base {
171 Ok(output) if output.status.success() => {
172 String::from_utf8_lossy(&output.stdout).trim().to_string()
173 }
174 _ => return true, };
176
177 let result = Command::new("git")
178 .args(["merge-tree", &merge_base_sha, base, branch])
179 .current_dir(repo)
180 .output();
181 match result {
182 Ok(output) => {
183 let stdout = String::from_utf8_lossy(&output.stdout);
184 stdout.contains("<<<<<<") || stdout.contains("changed in both")
185 }
186 Err(_) => true, }
188}
189
190fn is_migration_file(path: &str) -> bool {
192 let lower = path.to_lowercase();
193 lower.contains("migration")
194 || lower.contains("migrate")
195 || lower.contains("/db/")
196 || lower.contains("schema")
197 || lower.ends_with(".sql")
198}
199
200fn is_config_file(path: &str) -> bool {
202 let lower = path.to_lowercase();
203 lower.ends_with(".yaml")
204 || lower.ends_with(".yml")
205 || lower.ends_with(".toml")
206 || lower.ends_with(".json")
207 || lower.ends_with(".env")
208 || lower.ends_with(".env.example")
209}
210
211pub fn compute_merge_confidence(summary: &DiffSummary, policy: &AutoMergePolicy) -> f64 {
213 let mut confidence = 1.0f64;
214
215 if summary.files_changed > 3 {
217 confidence -= 0.1 * (summary.files_changed - 3) as f64;
218 }
219
220 if summary.modules_touched.len() > 1 {
222 confidence -= 0.2 * (summary.modules_touched.len() - 1) as f64;
223 }
224
225 let touches_sensitive = summary
227 .sensitive_files
228 .iter()
229 .any(|f| policy.sensitive_paths.iter().any(|s| f.contains(s)));
230 if touches_sensitive {
231 confidence -= 0.3;
232 }
233
234 let total_lines = summary.total_lines();
236 if total_lines > 100 {
237 let excess = total_lines - 100;
238 confidence -= 0.1 * (excess / 50) as f64;
239 }
240
241 if summary.has_unsafe {
243 confidence -= 0.4;
244 }
245
246 if summary.has_conflicts {
248 confidence -= 0.5;
249 }
250
251 if summary.has_migrations {
253 confidence -= 0.3;
254 }
255
256 if summary.has_config_changes {
258 confidence -= 0.15;
259 }
260
261 if summary.rename_count > 0 && summary.files_changed > 0 {
263 let rename_ratio = summary.rename_count as f64 / summary.files_changed as f64;
264 confidence += 0.1 * rename_ratio;
265 }
266
267 confidence.max(0.0)
269}
270
271pub fn should_auto_merge(
277 summary: &DiffSummary,
278 policy: &AutoMergePolicy,
279 tests_passed: bool,
280) -> AutoMergeDecision {
281 if !policy.enabled {
282 return AutoMergeDecision::ManualReview {
283 confidence: compute_merge_confidence(summary, policy),
284 reasons: vec!["auto-merge disabled by policy".to_string()],
285 };
286 }
287
288 let confidence = compute_merge_confidence(summary, policy);
289 let mut reasons = Vec::new();
290
291 if policy.require_tests_pass && !tests_passed {
292 reasons.push("tests did not pass".to_string());
293 }
294
295 if summary.has_conflicts {
296 reasons.push("conflicts with main".to_string());
297 }
298
299 if confidence < policy.confidence_threshold {
300 reasons.push(format!(
301 "confidence {:.2} below threshold {:.2}",
302 confidence, policy.confidence_threshold
303 ));
304 }
305
306 if summary.files_changed > policy.max_files_changed {
307 reasons.push(format!(
308 "{} files changed (max {})",
309 summary.files_changed, policy.max_files_changed
310 ));
311 }
312
313 let total_lines = summary.total_lines();
314 if total_lines > policy.max_diff_lines {
315 reasons.push(format!(
316 "{} diff lines (max {})",
317 total_lines, policy.max_diff_lines
318 ));
319 }
320
321 if summary.modules_touched.len() > policy.max_modules_touched {
322 reasons.push(format!(
323 "{} modules touched (max {})",
324 summary.modules_touched.len(),
325 policy.max_modules_touched
326 ));
327 }
328
329 let touches_sensitive = summary
330 .sensitive_files
331 .iter()
332 .any(|f| policy.sensitive_paths.iter().any(|s| f.contains(s)));
333 if touches_sensitive {
334 reasons.push("touches sensitive paths".to_string());
335 }
336
337 if summary.has_unsafe {
338 reasons.push("contains unsafe blocks".to_string());
339 }
340
341 if summary.has_migrations {
342 reasons.push("contains migration/schema changes".to_string());
343 }
344
345 if summary.has_config_changes {
346 reasons.push("contains config file changes".to_string());
347 }
348
349 if reasons.is_empty() {
350 AutoMergeDecision::AutoMerge { confidence }
351 } else {
352 AutoMergeDecision::ManualReview {
353 confidence,
354 reasons,
355 }
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 fn default_policy() -> AutoMergePolicy {
364 AutoMergePolicy::default()
365 }
366
367 fn enabled_policy() -> AutoMergePolicy {
368 AutoMergePolicy {
369 enabled: true,
370 ..AutoMergePolicy::default()
371 }
372 }
373
374 fn make_summary(
375 files: usize,
376 added: usize,
377 removed: usize,
378 modules: Vec<&str>,
379 sensitive: Vec<&str>,
380 has_unsafe: bool,
381 ) -> DiffSummary {
382 DiffSummary {
383 files_changed: files,
384 lines_added: added,
385 lines_removed: removed,
386 modules_touched: modules.into_iter().map(String::from).collect(),
387 sensitive_files: sensitive.into_iter().map(String::from).collect(),
388 has_unsafe,
389 has_conflicts: false,
390 rename_count: 0,
391 has_migrations: false,
392 has_config_changes: false,
393 }
394 }
395
396 #[test]
397 fn small_clean_diff_auto_merges() {
398 let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
399 let policy = enabled_policy();
400 let decision = should_auto_merge(&summary, &policy, true);
401 match decision {
402 AutoMergeDecision::AutoMerge { confidence } => {
403 assert!(
404 confidence >= 0.8,
405 "confidence should be >= 0.8, got {}",
406 confidence
407 );
408 }
409 other => panic!("expected AutoMerge, got {:?}", other),
410 }
411 }
412
413 #[test]
414 fn large_diff_routes_to_review() {
415 let summary = make_summary(3, 200, 100, vec!["team"], vec![], false);
416 let policy = enabled_policy();
417 let decision = should_auto_merge(&summary, &policy, true);
418 match decision {
419 AutoMergeDecision::ManualReview { reasons, .. } => {
420 assert!(
421 reasons.iter().any(|r| r.contains("diff lines")),
422 "should mention diff lines: {:?}",
423 reasons
424 );
425 }
426 other => panic!("expected ManualReview, got {:?}", other),
427 }
428 }
429
430 #[test]
431 fn sensitive_file_routes_to_review() {
432 let summary = make_summary(2, 20, 10, vec!["team"], vec!["Cargo.toml"], false);
433 let policy = enabled_policy();
434 let decision = should_auto_merge(&summary, &policy, true);
435 match decision {
436 AutoMergeDecision::ManualReview { reasons, .. } => {
437 assert!(
438 reasons.iter().any(|r| r.contains("sensitive")),
439 "should mention sensitive paths: {:?}",
440 reasons
441 );
442 }
443 other => panic!("expected ManualReview, got {:?}", other),
444 }
445 }
446
447 #[test]
448 fn multi_module_reduces_confidence() {
449 let summary = make_summary(
450 4,
451 40,
452 10,
453 vec!["team", "cli", "tmux", "agent"],
454 vec![],
455 false,
456 );
457 let policy = enabled_policy();
458 let confidence = compute_merge_confidence(&summary, &policy);
459 assert!(
461 confidence < policy.confidence_threshold,
462 "confidence {} should be below threshold {}",
463 confidence,
464 policy.confidence_threshold
465 );
466 }
467
468 #[test]
469 fn confidence_floor_at_zero() {
470 let summary = make_summary(
471 20,
472 2000,
473 1000,
474 vec!["team", "cli", "tmux", "agent", "config"],
475 vec!["Cargo.toml", ".env"],
476 true,
477 );
478 let policy = enabled_policy();
479 let confidence = compute_merge_confidence(&summary, &policy);
480 assert_eq!(confidence, 0.0, "confidence should be floored at 0.0");
481 }
482
483 #[test]
484 fn disabled_policy_always_manual() {
485 let summary = make_summary(1, 5, 2, vec!["team"], vec![], false);
486 let policy = default_policy(); let decision = should_auto_merge(&summary, &policy, true);
488 match decision {
489 AutoMergeDecision::ManualReview { reasons, .. } => {
490 assert!(
491 reasons.iter().any(|r| r.contains("disabled")),
492 "should mention disabled: {:?}",
493 reasons
494 );
495 }
496 other => panic!("expected ManualReview, got {:?}", other),
497 }
498 }
499
500 #[test]
501 fn config_deserializes_with_defaults() {
502 let yaml = "{}";
503 let policy: AutoMergePolicy = serde_yaml::from_str(yaml).unwrap();
504 assert!(!policy.enabled);
505 assert_eq!(policy.max_diff_lines, 200);
506 assert_eq!(policy.max_files_changed, 5);
507 assert_eq!(policy.max_modules_touched, 2);
508 assert_eq!(policy.confidence_threshold, 0.8);
509 assert!(policy.require_tests_pass);
510 assert!(policy.sensitive_paths.contains(&"Cargo.toml".to_string()));
511 }
512
513 #[test]
514 fn unsafe_blocks_reduce_confidence() {
515 let summary = make_summary(2, 30, 20, vec!["team"], vec![], true);
516 let policy = enabled_policy();
517 let confidence = compute_merge_confidence(&summary, &policy);
518 assert!(
520 (confidence - 0.6).abs() < 0.001,
521 "confidence should be 0.6, got {}",
522 confidence
523 );
524 }
525
526 #[test]
527 fn tests_not_passed_routes_to_review() {
528 let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
529 let policy = enabled_policy();
530 let decision = should_auto_merge(&summary, &policy, false);
531 match decision {
532 AutoMergeDecision::ManualReview { reasons, .. } => {
533 assert!(
534 reasons.iter().any(|r| r.contains("tests did not pass")),
535 "should mention tests: {:?}",
536 reasons
537 );
538 }
539 other => panic!("expected ManualReview, got {:?}", other),
540 }
541 }
542
543 #[test]
544 fn tests_not_required_allows_merge_without_passing() {
545 let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
546 let mut policy = enabled_policy();
547 policy.require_tests_pass = false;
548 let decision = should_auto_merge(&summary, &policy, false);
549 match decision {
550 AutoMergeDecision::AutoMerge { .. } => {}
551 other => panic!(
552 "expected AutoMerge when tests not required, got {:?}",
553 other
554 ),
555 }
556 }
557
558 #[test]
559 fn conflicts_reduce_confidence_and_route_to_review() {
560 let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
561 summary.has_conflicts = true;
562 let policy = enabled_policy();
563 let confidence = compute_merge_confidence(&summary, &policy);
564 assert!(
566 (confidence - 0.5).abs() < 0.001,
567 "confidence should be 0.5, got {}",
568 confidence
569 );
570 let decision = should_auto_merge(&summary, &policy, true);
571 match decision {
572 AutoMergeDecision::ManualReview { reasons, .. } => {
573 assert!(
574 reasons.iter().any(|r| r.contains("conflicts")),
575 "should mention conflicts: {:?}",
576 reasons
577 );
578 }
579 other => panic!("expected ManualReview, got {:?}", other),
580 }
581 }
582
583 #[test]
584 fn migrations_reduce_confidence() {
585 let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
586 summary.has_migrations = true;
587 let policy = enabled_policy();
588 let confidence = compute_merge_confidence(&summary, &policy);
589 assert!(
591 (confidence - 0.7).abs() < 0.001,
592 "confidence should be 0.7, got {}",
593 confidence
594 );
595 }
596
597 #[test]
598 fn migrations_route_to_review() {
599 let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
600 summary.has_migrations = true;
601 let policy = enabled_policy();
602 let decision = should_auto_merge(&summary, &policy, true);
603 match decision {
604 AutoMergeDecision::ManualReview { reasons, .. } => {
605 assert!(
606 reasons.iter().any(|r| r.contains("migration")),
607 "should mention migration: {:?}",
608 reasons
609 );
610 }
611 other => panic!("expected ManualReview, got {:?}", other),
612 }
613 }
614
615 #[test]
616 fn config_changes_reduce_confidence() {
617 let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
618 summary.has_config_changes = true;
619 let policy = enabled_policy();
620 let confidence = compute_merge_confidence(&summary, &policy);
621 assert!(
623 (confidence - 0.85).abs() < 0.001,
624 "confidence should be 0.85, got {}",
625 confidence
626 );
627 }
628
629 #[test]
630 fn config_changes_route_to_review() {
631 let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
632 summary.has_config_changes = true;
633 let policy = enabled_policy();
634 let decision = should_auto_merge(&summary, &policy, true);
635 match decision {
636 AutoMergeDecision::ManualReview { reasons, .. } => {
637 assert!(
638 reasons.iter().any(|r| r.contains("config")),
639 "should mention config: {:?}",
640 reasons
641 );
642 }
643 other => panic!("expected ManualReview, got {:?}", other),
644 }
645 }
646
647 #[test]
648 fn renames_boost_confidence() {
649 let mut summary = make_summary(4, 10, 10, vec!["team"], vec![], false);
650 summary.rename_count = 3;
652 let policy = enabled_policy();
653 let confidence_with_renames = compute_merge_confidence(&summary, &policy);
654
655 let summary_no_renames = make_summary(4, 10, 10, vec!["team"], vec![], false);
656 let confidence_without = compute_merge_confidence(&summary_no_renames, &policy);
657
658 assert!(
659 confidence_with_renames > confidence_without,
660 "renames should boost confidence: with={}, without={}",
661 confidence_with_renames,
662 confidence_without
663 );
664 }
665
666 #[test]
667 fn all_renames_gives_full_boost() {
668 let mut summary = make_summary(4, 0, 0, vec!["team"], vec![], false);
669 summary.rename_count = 4;
670 let policy = enabled_policy();
671 let confidence = compute_merge_confidence(&summary, &policy);
672 assert!(
674 (confidence - 1.0).abs() < 0.001,
675 "all-rename diff should have full confidence: {}",
676 confidence
677 );
678 }
679
680 #[test]
681 fn migration_file_detection() {
682 assert!(is_migration_file("db/migrate/001_add_users.sql"));
683 assert!(is_migration_file("src/migrations/v2.rs"));
684 assert!(is_migration_file("schema.sql"));
685 assert!(!is_migration_file("src/team/mod.rs"));
686 }
687
688 #[test]
689 fn config_file_detection() {
690 assert!(is_config_file("team.yaml"));
691 assert!(is_config_file("Cargo.toml"));
692 assert!(is_config_file("package.json"));
693 assert!(is_config_file(".env"));
694 assert!(!is_config_file("src/team/config.rs"));
695 }
696
697 #[test]
698 fn combined_risk_factors_accumulate() {
699 let mut summary = make_summary(
700 6,
701 200,
702 100,
703 vec!["team", "cli", "tmux"],
704 vec!["Cargo.toml"],
705 true,
706 );
707 summary.has_migrations = true;
708 summary.has_config_changes = true;
709 summary.has_conflicts = true;
710 let policy = enabled_policy();
711 let confidence = compute_merge_confidence(&summary, &policy);
712 assert_eq!(confidence, 0.0, "extreme risk diff should floor at 0.0");
713 }
714
715 #[test]
716 fn override_persistence_roundtrip() {
717 let tmp = tempfile::tempdir().unwrap();
718 let root = tmp.path();
719 std::fs::create_dir_all(root.join(".batty")).unwrap();
720
721 assert!(load_overrides(root).is_empty());
723
724 save_override(root, 42, true).unwrap();
726 let overrides = load_overrides(root);
727 assert_eq!(overrides.get(&42), Some(&true));
728
729 save_override(root, 99, false).unwrap();
731 let overrides = load_overrides(root);
732 assert_eq!(overrides.get(&42), Some(&true));
733 assert_eq!(overrides.get(&99), Some(&false));
734
735 save_override(root, 42, false).unwrap();
737 let overrides = load_overrides(root);
738 assert_eq!(overrides.get(&42), Some(&false));
739 }
740
741 #[test]
744 fn load_overrides_malformed_json_returns_empty() {
745 let tmp = tempfile::tempdir().unwrap();
746 let root = tmp.path();
747 let path = root.join(OVERRIDES_FILE);
748 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
749 std::fs::write(&path, "not valid json {{{}").unwrap();
750 assert!(load_overrides(root).is_empty());
751 }
752
753 #[test]
754 fn load_overrides_wrong_json_type_returns_empty() {
755 let tmp = tempfile::tempdir().unwrap();
756 let root = tmp.path();
757 let path = root.join(OVERRIDES_FILE);
758 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
759 std::fs::write(&path, "[1, 2, 3]").unwrap();
761 assert!(load_overrides(root).is_empty());
762 }
763
764 #[test]
765 fn save_override_creates_batty_dir() {
766 let tmp = tempfile::tempdir().unwrap();
767 let root = tmp.path();
768 save_override(root, 1, true).unwrap();
770 assert!(root.join(OVERRIDES_FILE).exists());
771 }
772
773 #[test]
774 fn save_override_to_readonly_dir_returns_error() {
775 #[cfg(unix)]
776 {
777 use std::os::unix::fs::PermissionsExt;
778 let tmp = tempfile::tempdir().unwrap();
779 let root = tmp.path();
780 let batty_dir = root.join(".batty");
781 std::fs::create_dir(&batty_dir).unwrap();
782 std::fs::set_permissions(&batty_dir, std::fs::Permissions::from_mode(0o444)).unwrap();
783
784 let result = save_override(root, 1, true);
785 assert!(result.is_err());
786
787 std::fs::set_permissions(&batty_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
789 }
790 }
791
792 #[test]
793 fn analyze_diff_on_non_git_dir_returns_empty_summary() {
794 let tmp = tempfile::tempdir().unwrap();
795 let result = analyze_diff(tmp.path(), "main", "feature");
798 if let Ok(summary) = result {
799 assert_eq!(summary.files_changed, 0);
800 assert_eq!(summary.total_lines(), 0);
801 }
802 }
804}