Skip to main content

batty_cli/team/
auto_merge.rs

1//! Auto-merge policy engine with confidence scoring.
2//!
3//! Evaluates completed task diffs and decides whether to auto-merge
4//! or route to manual review based on configurable thresholds.
5
6use std::collections::{HashMap, HashSet};
7use std::path::Path;
8use std::process::Command;
9
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12
13use super::config::AutoMergePolicy;
14
15const OVERRIDES_FILE: &str = ".batty/auto_merge_overrides.json";
16
17/// Load per-task auto-merge overrides from disk.
18pub fn load_overrides(project_root: &Path) -> HashMap<u32, bool> {
19    let path = project_root.join(OVERRIDES_FILE);
20    let Ok(content) = std::fs::read_to_string(&path) else {
21        return HashMap::new();
22    };
23    serde_json::from_str(&content).unwrap_or_default()
24}
25
26/// Save a per-task auto-merge override to disk.
27pub fn save_override(project_root: &Path, task_id: u32, enabled: bool) -> Result<()> {
28    let path = project_root.join(OVERRIDES_FILE);
29    let mut overrides = load_overrides(project_root);
30    overrides.insert(task_id, enabled);
31    let content = serde_json::to_string_pretty(&overrides)
32        .context("failed to serialize auto-merge overrides")?;
33    if let Some(parent) = path.parent() {
34        std::fs::create_dir_all(parent).ok();
35    }
36    std::fs::write(&path, content).context("failed to write auto-merge overrides file")?;
37    Ok(())
38}
39
40/// Summary of a git diff between two refs.
41#[derive(Debug, Clone)]
42pub struct DiffSummary {
43    pub files_changed: usize,
44    pub lines_added: usize,
45    pub lines_removed: usize,
46    pub generated_lines_added: usize,
47    pub generated_lines_removed: usize,
48    pub modules_touched: HashSet<String>,
49    pub sensitive_files: Vec<String>,
50    pub has_unsafe: bool,
51    pub has_conflicts: bool,
52    /// Number of renamed files (pure renames are lower risk).
53    pub rename_count: usize,
54    /// Whether the diff touches migration-like files (schema changes, etc.).
55    pub has_migrations: bool,
56    /// Whether the diff touches config files (YAML, TOML, JSON config).
57    pub has_config_changes: bool,
58}
59
60impl DiffSummary {
61    pub fn total_lines(&self) -> usize {
62        self.lines_added + self.lines_removed
63    }
64
65    pub fn generated_data_lines(&self) -> usize {
66        self.generated_lines_added + self.generated_lines_removed
67    }
68
69    pub fn review_lines(&self) -> usize {
70        self.total_lines()
71            .saturating_sub(self.generated_data_lines())
72    }
73}
74
75/// Decision returned by the policy engine.
76#[derive(Debug, Clone, PartialEq)]
77pub enum AutoMergeDecision {
78    AutoMerge {
79        confidence: f64,
80    },
81    ManualReview {
82        confidence: f64,
83        reasons: Vec<String>,
84    },
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum AutoMergeDecisionKind {
90    Accepted,
91    ManualReview,
92}
93
94impl AutoMergeDecisionKind {
95    pub fn action_type(self) -> &'static str {
96        match self {
97            Self::Accepted => "accepted",
98            Self::ManualReview => "manual_review",
99        }
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct AutoMergeDecisionRecord {
105    pub decision: AutoMergeDecisionKind,
106    pub confidence: f64,
107    pub reasons: Vec<String>,
108    pub files_changed: usize,
109    pub lines_changed: usize,
110    pub modules_touched: usize,
111    pub has_migrations: bool,
112    pub has_config_changes: bool,
113    pub has_unsafe: bool,
114    pub has_conflicts: bool,
115    pub rename_count: usize,
116    pub tests_passed: bool,
117    pub override_forced: Option<bool>,
118    pub diff_available: bool,
119}
120
121impl AutoMergeDecisionRecord {
122    fn from_summary(
123        summary: Option<&DiffSummary>,
124        confidence: f64,
125        decision: AutoMergeDecisionKind,
126        reasons: Vec<String>,
127        tests_passed: bool,
128        override_forced: Option<bool>,
129    ) -> Self {
130        Self {
131            decision,
132            confidence,
133            reasons,
134            files_changed: summary.map_or(0, |value| value.files_changed),
135            lines_changed: summary.map_or(0, DiffSummary::total_lines),
136            modules_touched: summary.map_or(0, |value| value.modules_touched.len()),
137            has_migrations: summary.is_some_and(|value| value.has_migrations),
138            has_config_changes: summary.is_some_and(|value| value.has_config_changes),
139            has_unsafe: summary.is_some_and(|value| value.has_unsafe),
140            has_conflicts: summary.is_some_and(|value| value.has_conflicts),
141            rename_count: summary.map_or(0, |value| value.rename_count),
142            tests_passed,
143            override_forced,
144            diff_available: summary.is_some(),
145        }
146    }
147}
148
149/// Analyze the diff between `base` and `branch` in the given repo.
150pub fn analyze_diff(repo: &Path, base: &str, branch: &str) -> Result<DiffSummary> {
151    // Get --stat for file count and per-file changes
152    let stat_output = Command::new("git")
153        .args(["diff", "--numstat", &format!("{}...{}", base, branch)])
154        .current_dir(repo)
155        .output()
156        .context("failed to run git diff --numstat")?;
157
158    let stat_str = String::from_utf8_lossy(&stat_output.stdout);
159
160    let mut files_changed = 0usize;
161    let mut lines_added = 0usize;
162    let mut lines_removed = 0usize;
163    let mut generated_lines_added = 0usize;
164    let mut generated_lines_removed = 0usize;
165    let mut modules_touched = HashSet::new();
166    let mut changed_paths = Vec::new();
167
168    for line in stat_str.lines() {
169        let parts: Vec<&str> = line.split('\t').collect();
170        if parts.len() < 3 {
171            continue;
172        }
173        files_changed += 1;
174        let added = parts[0].parse::<usize>().ok();
175        let removed = parts[1].parse::<usize>().ok();
176        if let Some(added) = added {
177            lines_added += added;
178        }
179        if let Some(removed) = removed {
180            lines_removed += removed;
181        }
182        let path = parts[2];
183        changed_paths.push(path.to_string());
184        if is_generated_data_file(path) {
185            if let Some(added) = added {
186                generated_lines_added += added;
187            }
188            if let Some(removed) = removed {
189                generated_lines_removed += removed;
190            }
191        }
192
193        // Extract top-level module (first component under src/)
194        if let Some(rest) = path.strip_prefix("src/") {
195            if let Some(module) = rest.split('/').next() {
196                modules_touched.insert(module.to_string());
197            }
198        }
199    }
200
201    // Get full diff to check for unsafe blocks
202    let diff_output = Command::new("git")
203        .args(["diff", &format!("{}...{}", base, branch)])
204        .current_dir(repo)
205        .output()
206        .context("failed to run git diff")?;
207
208    let diff_str = String::from_utf8_lossy(&diff_output.stdout);
209    let has_unsafe = diff_str.lines().any(|line| {
210        line.starts_with('+') && (line.contains("unsafe {") || line.contains("unsafe fn"))
211    });
212
213    // Count renames (pure renames are lower risk than logic changes)
214    let rename_output = Command::new("git")
215        .args([
216            "diff",
217            "--diff-filter=R",
218            "--name-only",
219            &format!("{}...{}", base, branch),
220        ])
221        .current_dir(repo)
222        .output()
223        .context("failed to run git diff --diff-filter=R")?;
224    let rename_count = String::from_utf8_lossy(&rename_output.stdout)
225        .lines()
226        .filter(|l| !l.is_empty())
227        .count();
228
229    // Detect migration and config file changes
230    let has_migrations = changed_paths.iter().any(|p| is_migration_file(p));
231    let has_config_changes = changed_paths.iter().any(|p| is_config_file(p));
232
233    // Check if branch can merge cleanly into base
234    let has_conflicts = check_has_conflicts(repo, base, branch);
235
236    Ok(DiffSummary {
237        files_changed,
238        lines_added,
239        lines_removed,
240        generated_lines_added,
241        generated_lines_removed,
242        modules_touched,
243        sensitive_files: changed_paths, // filtered by caller via policy
244        has_unsafe,
245        has_conflicts,
246        rename_count,
247        has_migrations,
248        has_config_changes,
249    })
250}
251
252/// Check whether merging `branch` into `base` would produce conflicts.
253fn check_has_conflicts(repo: &Path, base: &str, branch: &str) -> bool {
254    let merge_base = Command::new("git")
255        .args(["merge-base", base, branch])
256        .current_dir(repo)
257        .output();
258    let merge_base_sha = match merge_base {
259        Ok(output) if output.status.success() => {
260            String::from_utf8_lossy(&output.stdout).trim().to_string()
261        }
262        _ => return true, // Can't find merge base — treat as conflicting
263    };
264
265    let result = Command::new("git")
266        .args(["merge-tree", &merge_base_sha, base, branch])
267        .current_dir(repo)
268        .output();
269    match result {
270        Ok(output) => {
271            let stdout = String::from_utf8_lossy(&output.stdout);
272            stdout.contains("<<<<<<") || stdout.contains("changed in both")
273        }
274        Err(_) => true, // merge-tree failed — assume conflicts
275    }
276}
277
278/// Returns true if the path looks like a migration file.
279fn is_migration_file(path: &str) -> bool {
280    let lower = path.to_lowercase();
281    lower.contains("migration")
282        || lower.contains("migrate")
283        || lower.contains("/db/")
284        || lower.contains("schema")
285        || lower.ends_with(".sql")
286}
287
288fn is_generated_data_file(path: &str) -> bool {
289    let lower = path.to_lowercase();
290    (lower.contains("generated/") || lower.contains("reference/") || lower.contains("fixtures/"))
291        && !lower.starts_with("src/")
292}
293
294/// Returns true if the path looks like a config file (not source code).
295///
296/// Generated data files (under `generated/`, `reference/`, test fixtures,
297/// or lockfiles) are excluded — they are outputs, not configuration.
298fn is_config_file(path: &str) -> bool {
299    let lower = path.to_lowercase();
300    let has_config_ext = lower.ends_with(".yaml")
301        || lower.ends_with(".yml")
302        || lower.ends_with(".toml")
303        || lower.ends_with(".json")
304        || lower.ends_with(".env")
305        || lower.ends_with(".env.example");
306    if !has_config_ext {
307        return false;
308    }
309    // Exclude generated/reference data, test fixtures, and lockfiles
310    let is_generated = lower.contains("generated/")
311        || lower.contains("reference/")
312        || lower.contains("fixtures/")
313        || lower.contains("tests/")
314        || lower.ends_with(".lock")
315        || lower.ends_with("lock.json");
316    has_config_ext && !is_generated
317}
318
319/// Compute merge confidence score (0.0-1.0) from a diff summary and policy.
320pub fn compute_merge_confidence(summary: &DiffSummary, policy: &AutoMergePolicy) -> f64 {
321    let mut confidence = 1.0f64;
322
323    // Subtract 0.1 per file over 3
324    if summary.files_changed > 3 {
325        confidence -= 0.1 * (summary.files_changed - 3) as f64;
326    }
327
328    // Subtract 0.2 per module touched over 1
329    if summary.modules_touched.len() > 1 {
330        confidence -= 0.2 * (summary.modules_touched.len() - 1) as f64;
331    }
332
333    // Subtract 0.3 if any sensitive path touched
334    let touches_sensitive = summary
335        .sensitive_files
336        .iter()
337        .any(|f| policy.sensitive_paths.iter().any(|s| f.contains(s)));
338    if touches_sensitive {
339        confidence -= 0.3;
340    }
341
342    // Subtract 0.1 per 50 lines over 100
343    let total_lines = summary.total_lines();
344    if total_lines > 100 {
345        let excess = total_lines - 100;
346        confidence -= 0.1 * (excess / 50) as f64;
347    }
348
349    // Subtract 0.4 if unsafe blocks or FFI
350    if summary.has_unsafe {
351        confidence -= 0.4;
352    }
353
354    // Subtract 0.5 if conflicts detected with main
355    if summary.has_conflicts {
356        confidence -= 0.5;
357    }
358
359    // Subtract 0.3 for migration/schema changes (high risk)
360    if summary.has_migrations {
361        confidence -= 0.3;
362    }
363
364    // Subtract 0.15 for config file changes
365    if summary.has_config_changes {
366        confidence -= 0.15;
367    }
368
369    // Boost confidence when most changes are renames (low-risk)
370    if summary.rename_count > 0 && summary.files_changed > 0 {
371        let rename_ratio = summary.rename_count as f64 / summary.files_changed as f64;
372        confidence += 0.1 * rename_ratio;
373    }
374
375    // Floor at 0.0
376    confidence.max(0.0)
377}
378
379pub fn score_auto_merge_candidate(summary: &DiffSummary, policy: &AutoMergePolicy) -> f64 {
380    compute_merge_confidence(summary, policy)
381}
382
383pub fn evaluate_auto_merge_candidate(
384    summary: &DiffSummary,
385    policy: &AutoMergePolicy,
386    tests_passed: bool,
387) -> AutoMergeDecisionRecord {
388    if !policy.enabled {
389        return AutoMergeDecisionRecord::from_summary(
390            Some(summary),
391            score_auto_merge_candidate(summary, policy),
392            AutoMergeDecisionKind::ManualReview,
393            vec!["auto-merge disabled by policy".to_string()],
394            tests_passed,
395            None,
396        );
397    }
398
399    let confidence = score_auto_merge_candidate(summary, policy);
400    let mut reasons = Vec::new();
401
402    if policy.require_tests_pass && !tests_passed {
403        reasons.push("tests did not pass".to_string());
404    }
405
406    if summary.has_conflicts {
407        reasons.push("conflicts with main".to_string());
408    }
409
410    if confidence < policy.confidence_threshold {
411        reasons.push(format!(
412            "confidence {:.2} below threshold {:.2}",
413            confidence, policy.confidence_threshold
414        ));
415    }
416
417    if summary.files_changed > policy.max_files_changed {
418        reasons.push(format!(
419            "{} files changed (max {})",
420            summary.files_changed, policy.max_files_changed
421        ));
422    }
423
424    let review_lines = summary.review_lines();
425    if review_lines > policy.max_diff_lines {
426        reasons.push(format!(
427            "{} diff lines (max {})",
428            review_lines, policy.max_diff_lines
429        ));
430    }
431
432    if summary.modules_touched.len() > policy.max_modules_touched {
433        reasons.push(format!(
434            "{} modules touched (max {})",
435            summary.modules_touched.len(),
436            policy.max_modules_touched
437        ));
438    }
439
440    let touches_sensitive = summary
441        .sensitive_files
442        .iter()
443        .any(|f| policy.sensitive_paths.iter().any(|s| f.contains(s)));
444    if touches_sensitive {
445        reasons.push("touches sensitive paths".to_string());
446    }
447
448    if summary.has_unsafe {
449        reasons.push("contains unsafe blocks".to_string());
450    }
451
452    if summary.has_migrations {
453        reasons.push("contains migration/schema changes".to_string());
454    }
455
456    // Config changes are a soft signal — they already reduce confidence by
457    // 0.15 in compute_merge_confidence.  If that drops below threshold the
458    // confidence check above catches it.  Don't hard-block: the manager
459    // review path is unreliable for codex agents, so routing there just
460    // stalls the pipeline.
461
462    if reasons.is_empty() {
463        AutoMergeDecisionRecord::from_summary(
464            Some(summary),
465            confidence,
466            AutoMergeDecisionKind::Accepted,
467            vec![format!(
468                "confidence {:.2} meets threshold {:.2}; diff stays within file/module/line policy limits",
469                confidence, policy.confidence_threshold
470            )],
471            tests_passed,
472            None,
473        )
474    } else {
475        AutoMergeDecisionRecord::from_summary(
476            Some(summary),
477            confidence,
478            AutoMergeDecisionKind::ManualReview,
479            reasons,
480            tests_passed,
481            None,
482        )
483    }
484}
485
486pub fn forced_auto_merge_decision(
487    summary: Option<&DiffSummary>,
488    policy: &AutoMergePolicy,
489    tests_passed: bool,
490) -> AutoMergeDecisionRecord {
491    AutoMergeDecisionRecord::from_summary(
492        summary,
493        summary.map_or(0.0, |value| score_auto_merge_candidate(value, policy)),
494        AutoMergeDecisionKind::Accepted,
495        vec!["auto-merge forced by per-task override".to_string()],
496        tests_passed,
497        Some(true),
498    )
499}
500
501pub fn forced_manual_review_decision(
502    summary: Option<&DiffSummary>,
503    policy: &AutoMergePolicy,
504    tests_passed: bool,
505) -> AutoMergeDecisionRecord {
506    AutoMergeDecisionRecord::from_summary(
507        summary,
508        summary.map_or(0.0, |value| score_auto_merge_candidate(value, policy)),
509        AutoMergeDecisionKind::ManualReview,
510        vec!["auto-merge disabled by per-task override".to_string()],
511        tests_passed,
512        Some(false),
513    )
514}
515
516pub fn explain_auto_merge_decision(record: &AutoMergeDecisionRecord) -> String {
517    let decision = match record.decision {
518        AutoMergeDecisionKind::Accepted => "accepted for auto-merge",
519        AutoMergeDecisionKind::ManualReview => "routed to manual review",
520    };
521    let override_text = match record.override_forced {
522        Some(true) => " (forced by override)",
523        Some(false) => " (disabled by override)",
524        None => "",
525    };
526    let diff_shape = if record.diff_available {
527        format!(
528            "{} files, {} lines, {} modules",
529            record.files_changed, record.lines_changed, record.modules_touched
530        )
531    } else {
532        "diff summary unavailable".to_string()
533    };
534    format!(
535        "{decision}{override_text}: confidence {:.2}; {diff_shape}; reasons: {}",
536        record.confidence,
537        record.reasons.join("; ")
538    )
539}
540
541/// Decide whether to auto-merge or route to manual review.
542///
543/// `tests_passed` indicates whether the task's test suite passed. When
544/// `policy.require_tests_pass` is true and tests haven't passed, the
545/// decision is always manual review regardless of other criteria.
546pub fn should_auto_merge(
547    summary: &DiffSummary,
548    policy: &AutoMergePolicy,
549    tests_passed: bool,
550) -> AutoMergeDecision {
551    let record = evaluate_auto_merge_candidate(summary, policy, tests_passed);
552    match record.decision {
553        AutoMergeDecisionKind::Accepted => AutoMergeDecision::AutoMerge {
554            confidence: record.confidence,
555        },
556        AutoMergeDecisionKind::ManualReview => AutoMergeDecision::ManualReview {
557            confidence: record.confidence,
558            reasons: record.reasons,
559        },
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    fn default_policy() -> AutoMergePolicy {
568        AutoMergePolicy::default()
569    }
570
571    fn enabled_policy() -> AutoMergePolicy {
572        AutoMergePolicy {
573            enabled: true,
574            ..AutoMergePolicy::default()
575        }
576    }
577
578    fn make_summary(
579        files: usize,
580        added: usize,
581        removed: usize,
582        modules: Vec<&str>,
583        sensitive: Vec<&str>,
584        has_unsafe: bool,
585    ) -> DiffSummary {
586        DiffSummary {
587            files_changed: files,
588            lines_added: added,
589            lines_removed: removed,
590            generated_lines_added: 0,
591            generated_lines_removed: 0,
592            modules_touched: modules.into_iter().map(String::from).collect(),
593            sensitive_files: sensitive.into_iter().map(String::from).collect(),
594            has_unsafe,
595            has_conflicts: false,
596            rename_count: 0,
597            has_migrations: false,
598            has_config_changes: false,
599        }
600    }
601
602    #[test]
603    fn small_clean_diff_auto_merges() {
604        let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
605        let policy = enabled_policy();
606        let decision = should_auto_merge(&summary, &policy, true);
607        match decision {
608            AutoMergeDecision::AutoMerge { confidence } => {
609                assert!(
610                    confidence >= 0.8,
611                    "confidence should be >= 0.8, got {}",
612                    confidence
613                );
614            }
615            other => panic!("expected AutoMerge, got {:?}", other),
616        }
617    }
618
619    #[test]
620    fn large_diff_routes_to_review() {
621        // With relaxed thresholds (max_diff_lines=2000), need a truly large diff
622        let summary = make_summary(3, 1500, 600, vec!["team"], vec![], false);
623        let policy = enabled_policy();
624        let decision = should_auto_merge(&summary, &policy, true);
625        match decision {
626            AutoMergeDecision::ManualReview { reasons, .. } => {
627                assert!(
628                    reasons.iter().any(|r| r.contains("diff lines")),
629                    "should mention diff lines: {:?}",
630                    reasons
631                );
632            }
633            other => panic!("expected ManualReview, got {:?}", other),
634        }
635    }
636
637    #[test]
638    fn sensitive_file_routes_to_review() {
639        let summary = make_summary(2, 20, 10, vec!["team"], vec!["Cargo.toml"], false);
640        let policy = enabled_policy();
641        let decision = should_auto_merge(&summary, &policy, true);
642        match decision {
643            AutoMergeDecision::ManualReview { reasons, .. } => {
644                assert!(
645                    reasons.iter().any(|r| r.contains("sensitive")),
646                    "should mention sensitive paths: {:?}",
647                    reasons
648                );
649            }
650            other => panic!("expected ManualReview, got {:?}", other),
651        }
652    }
653
654    #[test]
655    fn multi_module_reduces_confidence() {
656        let summary = make_summary(
657            4,
658            40,
659            10,
660            vec!["team", "cli", "tmux", "agent"],
661            vec![],
662            false,
663        );
664        let policy = enabled_policy();
665        let confidence = compute_merge_confidence(&summary, &policy);
666        // 1.0 - 0.1*(4-3) - 0.2*(4-1) = 1.0 - 0.1 - 0.6 = 0.3
667        // Confidence is reduced but still above the relaxed threshold (0.0)
668        assert!(
669            confidence < 0.5,
670            "multi-module diff should have reduced confidence: {}",
671            confidence,
672        );
673    }
674
675    #[test]
676    fn confidence_floor_at_zero() {
677        let summary = make_summary(
678            20,
679            2000,
680            1000,
681            vec!["team", "cli", "tmux", "agent", "config"],
682            vec!["Cargo.toml", ".env"],
683            true,
684        );
685        let policy = enabled_policy();
686        let confidence = compute_merge_confidence(&summary, &policy);
687        assert_eq!(confidence, 0.0, "confidence should be floored at 0.0");
688    }
689
690    #[test]
691    fn disabled_policy_always_manual() {
692        let summary = make_summary(1, 5, 2, vec!["team"], vec![], false);
693        let mut policy = default_policy();
694        policy.enabled = false;
695        let decision = should_auto_merge(&summary, &policy, true);
696        match decision {
697            AutoMergeDecision::ManualReview { reasons, .. } => {
698                assert!(
699                    reasons.iter().any(|r| r.contains("disabled")),
700                    "should mention disabled: {:?}",
701                    reasons
702                );
703            }
704            other => panic!("expected ManualReview, got {:?}", other),
705        }
706    }
707
708    #[test]
709    fn config_deserializes_with_defaults() {
710        let yaml = "{}";
711        let policy: AutoMergePolicy = serde_yaml::from_str(yaml).unwrap();
712        assert!(policy.enabled);
713        assert_eq!(policy.max_diff_lines, 2000);
714        assert_eq!(policy.max_files_changed, 30);
715        assert_eq!(policy.max_modules_touched, 10);
716        assert_eq!(policy.confidence_threshold, 0.0);
717        assert!(policy.require_tests_pass);
718        assert!(policy.post_merge_verify);
719        assert!(policy.sensitive_paths.contains(&"Cargo.toml".to_string()));
720    }
721
722    #[test]
723    fn unsafe_blocks_reduce_confidence() {
724        let summary = make_summary(2, 30, 20, vec!["team"], vec![], true);
725        let policy = enabled_policy();
726        let confidence = compute_merge_confidence(&summary, &policy);
727        // 1.0 - 0.4 = 0.6
728        assert!(
729            (confidence - 0.6).abs() < 0.001,
730            "confidence should be 0.6, got {}",
731            confidence
732        );
733    }
734
735    #[test]
736    fn tests_not_passed_routes_to_review() {
737        let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
738        let policy = enabled_policy();
739        let decision = should_auto_merge(&summary, &policy, false);
740        match decision {
741            AutoMergeDecision::ManualReview { reasons, .. } => {
742                assert!(
743                    reasons.iter().any(|r| r.contains("tests did not pass")),
744                    "should mention tests: {:?}",
745                    reasons
746                );
747            }
748            other => panic!("expected ManualReview, got {:?}", other),
749        }
750    }
751
752    #[test]
753    fn tests_not_required_allows_merge_without_passing() {
754        let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
755        let mut policy = enabled_policy();
756        policy.require_tests_pass = false;
757        let decision = should_auto_merge(&summary, &policy, false);
758        match decision {
759            AutoMergeDecision::AutoMerge { .. } => {}
760            other => panic!(
761                "expected AutoMerge when tests not required, got {:?}",
762                other
763            ),
764        }
765    }
766
767    #[test]
768    fn conflicts_reduce_confidence_and_route_to_review() {
769        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
770        summary.has_conflicts = true;
771        let policy = enabled_policy();
772        let confidence = compute_merge_confidence(&summary, &policy);
773        // 1.0 - 0.5 = 0.5
774        assert!(
775            (confidence - 0.5).abs() < 0.001,
776            "confidence should be 0.5, got {}",
777            confidence
778        );
779        let decision = should_auto_merge(&summary, &policy, true);
780        match decision {
781            AutoMergeDecision::ManualReview { reasons, .. } => {
782                assert!(
783                    reasons.iter().any(|r| r.contains("conflicts")),
784                    "should mention conflicts: {:?}",
785                    reasons
786                );
787            }
788            other => panic!("expected ManualReview, got {:?}", other),
789        }
790    }
791
792    #[test]
793    fn migrations_reduce_confidence() {
794        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
795        summary.has_migrations = true;
796        let policy = enabled_policy();
797        let confidence = compute_merge_confidence(&summary, &policy);
798        // 1.0 - 0.3 = 0.7
799        assert!(
800            (confidence - 0.7).abs() < 0.001,
801            "confidence should be 0.7, got {}",
802            confidence
803        );
804    }
805
806    #[test]
807    fn migrations_route_to_review() {
808        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
809        summary.has_migrations = true;
810        let policy = enabled_policy();
811        let decision = should_auto_merge(&summary, &policy, true);
812        match decision {
813            AutoMergeDecision::ManualReview { reasons, .. } => {
814                assert!(
815                    reasons.iter().any(|r| r.contains("migration")),
816                    "should mention migration: {:?}",
817                    reasons
818                );
819            }
820            other => panic!("expected ManualReview, got {:?}", other),
821        }
822    }
823
824    #[test]
825    fn config_changes_reduce_confidence() {
826        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
827        summary.has_config_changes = true;
828        let policy = enabled_policy();
829        let confidence = compute_merge_confidence(&summary, &policy);
830        // 1.0 - 0.15 = 0.85
831        assert!(
832            (confidence - 0.85).abs() < 0.001,
833            "confidence should be 0.85, got {}",
834            confidence
835        );
836    }
837
838    #[test]
839    fn config_changes_auto_merge_when_confidence_above_threshold() {
840        // Config changes reduce confidence by 0.15 (1.0 → 0.85) but should
841        // still auto-merge since 0.85 > 0.80 threshold.  Config is a soft
842        // signal, not a hard blocker.
843        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
844        summary.has_config_changes = true;
845        let policy = enabled_policy();
846        let decision = should_auto_merge(&summary, &policy, true);
847        match decision {
848            AutoMergeDecision::AutoMerge { .. } => {} // expected
849            other => panic!("config-only change should auto-merge, got {:?}", other),
850        }
851    }
852
853    #[test]
854    fn renames_boost_confidence() {
855        let mut summary = make_summary(4, 10, 10, vec!["team"], vec![], false);
856        // 4 files changed, 3 of them are renames
857        summary.rename_count = 3;
858        let policy = enabled_policy();
859        let confidence_with_renames = compute_merge_confidence(&summary, &policy);
860
861        let summary_no_renames = make_summary(4, 10, 10, vec!["team"], vec![], false);
862        let confidence_without = compute_merge_confidence(&summary_no_renames, &policy);
863
864        assert!(
865            confidence_with_renames > confidence_without,
866            "renames should boost confidence: with={}, without={}",
867            confidence_with_renames,
868            confidence_without
869        );
870    }
871
872    #[test]
873    fn all_renames_gives_full_boost() {
874        let mut summary = make_summary(4, 0, 0, vec!["team"], vec![], false);
875        summary.rename_count = 4;
876        let policy = enabled_policy();
877        let confidence = compute_merge_confidence(&summary, &policy);
878        // 1.0 - 0.1*(4-3) + 0.1*(4/4) = 1.0 - 0.1 + 0.1 = 1.0
879        assert!(
880            (confidence - 1.0).abs() < 0.001,
881            "all-rename diff should have full confidence: {}",
882            confidence
883        );
884    }
885
886    #[test]
887    fn heterogeneous_but_bounded_diff_still_auto_merges() {
888        let summary = make_summary(3, 45, 15, vec!["team", "metrics"], vec![], false);
889        let policy = enabled_policy();
890        let record = evaluate_auto_merge_candidate(&summary, &policy, true);
891        assert_eq!(record.decision, AutoMergeDecisionKind::Accepted);
892        assert!(
893            record.reasons[0].contains("meets threshold"),
894            "should contain acceptance reason: {:?}",
895            record.reasons
896        );
897    }
898
899    #[test]
900    fn forced_override_decision_is_explicit() {
901        let summary = make_summary(5, 80, 20, vec!["team", "metrics", "daemon"], vec![], false);
902        let policy = enabled_policy();
903        let record = forced_auto_merge_decision(Some(&summary), &policy, true);
904        assert_eq!(record.decision, AutoMergeDecisionKind::Accepted);
905        assert_eq!(record.override_forced, Some(true));
906        assert_eq!(
907            explain_auto_merge_decision(&record),
908            "accepted for auto-merge (forced by override): confidence 0.40; 5 files, 100 lines, 3 modules; reasons: auto-merge forced by per-task override"
909        );
910    }
911
912    #[test]
913    fn migration_file_detection() {
914        assert!(is_migration_file("db/migrate/001_add_users.sql"));
915        assert!(is_migration_file("src/migrations/v2.rs"));
916        assert!(is_migration_file("schema.sql"));
917        assert!(!is_migration_file("src/team/mod.rs"));
918    }
919
920    #[test]
921    fn config_file_detection() {
922        assert!(is_config_file("team.yaml"));
923        assert!(is_config_file("Cargo.toml"));
924        assert!(is_config_file("package.json"));
925        assert!(is_config_file(".env"));
926        assert!(!is_config_file("src/team/config.rs"));
927    }
928
929    #[test]
930    fn generated_data_diff_does_not_trip_line_count_gate() {
931        let mut summary = make_summary(1, 39035, 0, vec![], vec!["generated/catalog.json"], false);
932        summary.generated_lines_added = 39035;
933        let policy = enabled_policy();
934        let decision = should_auto_merge(&summary, &policy, true);
935        match decision {
936            AutoMergeDecision::AutoMerge { .. } => {}
937            other => panic!(
938                "generated data extraction should auto-merge, got {:?}",
939                other
940            ),
941        }
942    }
943
944    #[test]
945    fn source_diff_still_trips_line_count_gate() {
946        let summary = make_summary(1, 2500, 0, vec!["team"], vec!["src/team/catalog.rs"], false);
947        let policy = enabled_policy();
948        let decision = should_auto_merge(&summary, &policy, true);
949        match decision {
950            AutoMergeDecision::ManualReview { reasons, .. } => {
951                assert!(
952                    reasons.iter().any(|r| r.contains("2500 diff lines")),
953                    "should mention gated source diff lines: {:?}",
954                    reasons
955                );
956            }
957            other => panic!(
958                "large source diff should route to manual review, got {:?}",
959                other
960            ),
961        }
962    }
963
964    #[test]
965    fn combined_risk_factors_accumulate() {
966        let mut summary = make_summary(
967            6,
968            200,
969            100,
970            vec!["team", "cli", "tmux"],
971            vec!["Cargo.toml"],
972            true,
973        );
974        summary.has_migrations = true;
975        summary.has_config_changes = true;
976        summary.has_conflicts = true;
977        let policy = enabled_policy();
978        let confidence = compute_merge_confidence(&summary, &policy);
979        assert_eq!(confidence, 0.0, "extreme risk diff should floor at 0.0");
980    }
981
982    #[test]
983    fn override_persistence_roundtrip() {
984        let tmp = tempfile::tempdir().unwrap();
985        let root = tmp.path();
986        std::fs::create_dir_all(root.join(".batty")).unwrap();
987
988        // No overrides file — returns empty
989        assert!(load_overrides(root).is_empty());
990
991        // Save override
992        save_override(root, 42, true).unwrap();
993        let overrides = load_overrides(root);
994        assert_eq!(overrides.get(&42), Some(&true));
995
996        // Save another override, first one persists
997        save_override(root, 99, false).unwrap();
998        let overrides = load_overrides(root);
999        assert_eq!(overrides.get(&42), Some(&true));
1000        assert_eq!(overrides.get(&99), Some(&false));
1001
1002        // Overwrite existing
1003        save_override(root, 42, false).unwrap();
1004        let overrides = load_overrides(root);
1005        assert_eq!(overrides.get(&42), Some(&false));
1006    }
1007
1008    // --- Error path and recovery tests (Task #265) ---
1009
1010    #[test]
1011    fn load_overrides_malformed_json_returns_empty() {
1012        let tmp = tempfile::tempdir().unwrap();
1013        let root = tmp.path();
1014        let path = root.join(OVERRIDES_FILE);
1015        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1016        std::fs::write(&path, "not valid json {{{}").unwrap();
1017        assert!(load_overrides(root).is_empty());
1018    }
1019
1020    #[test]
1021    fn load_overrides_wrong_json_type_returns_empty() {
1022        let tmp = tempfile::tempdir().unwrap();
1023        let root = tmp.path();
1024        let path = root.join(OVERRIDES_FILE);
1025        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1026        // Valid JSON but wrong type (array instead of object)
1027        std::fs::write(&path, "[1, 2, 3]").unwrap();
1028        assert!(load_overrides(root).is_empty());
1029    }
1030
1031    #[test]
1032    fn save_override_creates_batty_dir() {
1033        let tmp = tempfile::tempdir().unwrap();
1034        let root = tmp.path();
1035        // .batty dir doesn't exist yet
1036        save_override(root, 1, true).unwrap();
1037        assert!(root.join(OVERRIDES_FILE).exists());
1038    }
1039
1040    #[test]
1041    fn save_override_to_readonly_dir_returns_error() {
1042        #[cfg(unix)]
1043        {
1044            use std::os::unix::fs::PermissionsExt;
1045            let tmp = tempfile::tempdir().unwrap();
1046            let root = tmp.path();
1047            let batty_dir = root.join(".batty");
1048            std::fs::create_dir(&batty_dir).unwrap();
1049            std::fs::set_permissions(&batty_dir, std::fs::Permissions::from_mode(0o444)).unwrap();
1050
1051            let result = save_override(root, 1, true);
1052            assert!(result.is_err());
1053
1054            // Restore for cleanup
1055            std::fs::set_permissions(&batty_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1056        }
1057    }
1058
1059    #[test]
1060    fn analyze_diff_on_non_git_dir_returns_empty_summary() {
1061        let tmp = tempfile::tempdir().unwrap();
1062        // analyze_diff doesn't fail — git commands produce empty output on non-git dirs
1063        // This verifies graceful degradation rather than hard failure
1064        let result = analyze_diff(tmp.path(), "main", "feature");
1065        if let Ok(summary) = result {
1066            assert_eq!(summary.files_changed, 0);
1067            assert_eq!(summary.total_lines(), 0);
1068        }
1069        // If it errors, that's also acceptable graceful behavior
1070    }
1071}