use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::config::AutoMergePolicy;
const OVERRIDES_FILE: &str = ".batty/auto_merge_overrides.json";
pub fn load_overrides(project_root: &Path) -> HashMap<u32, bool> {
let path = project_root.join(OVERRIDES_FILE);
let Ok(content) = std::fs::read_to_string(&path) else {
return HashMap::new();
};
serde_json::from_str(&content).unwrap_or_default()
}
pub fn save_override(project_root: &Path, task_id: u32, enabled: bool) -> Result<()> {
let path = project_root.join(OVERRIDES_FILE);
let mut overrides = load_overrides(project_root);
overrides.insert(task_id, enabled);
let content = serde_json::to_string_pretty(&overrides)
.context("failed to serialize auto-merge overrides")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&path, content).context("failed to write auto-merge overrides file")?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct DiffSummary {
pub files_changed: usize,
pub lines_added: usize,
pub lines_removed: usize,
pub generated_lines_added: usize,
pub generated_lines_removed: usize,
pub modules_touched: HashSet<String>,
pub sensitive_files: Vec<String>,
pub generated_report_artifacts: Vec<String>,
pub has_unsafe: bool,
pub has_conflicts: bool,
pub rename_count: usize,
pub has_migrations: bool,
pub has_config_changes: bool,
}
impl DiffSummary {
pub fn total_lines(&self) -> usize {
self.lines_added + self.lines_removed
}
pub fn generated_data_lines(&self) -> usize {
self.generated_lines_added + self.generated_lines_removed
}
pub fn review_lines(&self) -> usize {
self.total_lines()
.saturating_sub(self.generated_data_lines())
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum AutoMergeDecision {
AutoMerge {
confidence: f64,
},
ManualReview {
confidence: f64,
reasons: Vec<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AutoMergeDecisionKind {
Accepted,
ManualReview,
}
impl AutoMergeDecisionKind {
pub fn action_type(self) -> &'static str {
match self {
Self::Accepted => "accepted",
Self::ManualReview => "manual_review",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AutoMergeDecisionRecord {
pub decision: AutoMergeDecisionKind,
pub confidence: f64,
pub reasons: Vec<String>,
pub files_changed: usize,
pub lines_changed: usize,
pub modules_touched: usize,
pub has_migrations: bool,
pub has_config_changes: bool,
pub has_unsafe: bool,
pub has_conflicts: bool,
pub rename_count: usize,
pub tests_passed: bool,
pub override_forced: Option<bool>,
pub diff_available: bool,
}
impl AutoMergeDecisionRecord {
fn from_summary(
summary: Option<&DiffSummary>,
confidence: f64,
decision: AutoMergeDecisionKind,
reasons: Vec<String>,
tests_passed: bool,
override_forced: Option<bool>,
) -> Self {
Self {
decision,
confidence,
reasons,
files_changed: summary.map_or(0, |value| value.files_changed),
lines_changed: summary.map_or(0, DiffSummary::total_lines),
modules_touched: summary.map_or(0, |value| value.modules_touched.len()),
has_migrations: summary.is_some_and(|value| value.has_migrations),
has_config_changes: summary.is_some_and(|value| value.has_config_changes),
has_unsafe: summary.is_some_and(|value| value.has_unsafe),
has_conflicts: summary.is_some_and(|value| value.has_conflicts),
rename_count: summary.map_or(0, |value| value.rename_count),
tests_passed,
override_forced,
diff_available: summary.is_some(),
}
}
}
pub fn analyze_diff(repo: &Path, base: &str, branch: &str) -> Result<DiffSummary> {
let stat_output = Command::new("git")
.args(["diff", "--numstat", &format!("{}...{}", base, branch)])
.current_dir(repo)
.output()
.context("failed to run git diff --numstat")?;
let stat_str = String::from_utf8_lossy(&stat_output.stdout);
let mut files_changed = 0usize;
let mut lines_added = 0usize;
let mut lines_removed = 0usize;
let mut generated_lines_added = 0usize;
let mut generated_lines_removed = 0usize;
let mut modules_touched = HashSet::new();
let mut changed_paths = Vec::new();
let mut generated_report_artifacts = Vec::new();
for line in stat_str.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 3 {
continue;
}
files_changed += 1;
let added = parts[0].parse::<usize>().ok();
let removed = parts[1].parse::<usize>().ok();
if let Some(added) = added {
lines_added += added;
}
if let Some(removed) = removed {
lines_removed += removed;
}
let path = parts[2];
changed_paths.push(path.to_string());
if is_generated_data_file(path) {
if let Some(added) = added {
generated_lines_added += added;
}
if let Some(removed) = removed {
generated_lines_removed += removed;
}
}
if is_generated_report_artifact(path) {
generated_report_artifacts.push(path.to_string());
}
if let Some(rest) = path.strip_prefix("src/") {
if let Some(module) = rest.split('/').next() {
modules_touched.insert(module.to_string());
}
}
}
let diff_output = Command::new("git")
.args(["diff", &format!("{}...{}", base, branch)])
.current_dir(repo)
.output()
.context("failed to run git diff")?;
let diff_str = String::from_utf8_lossy(&diff_output.stdout);
let has_unsafe = diff_str.lines().any(|line| {
line.starts_with('+') && (line.contains("unsafe {") || line.contains("unsafe fn"))
});
let rename_output = Command::new("git")
.args([
"diff",
"--diff-filter=R",
"--name-only",
&format!("{}...{}", base, branch),
])
.current_dir(repo)
.output()
.context("failed to run git diff --diff-filter=R")?;
let rename_count = String::from_utf8_lossy(&rename_output.stdout)
.lines()
.filter(|l| !l.is_empty())
.count();
let has_migrations = changed_paths.iter().any(|p| is_migration_file(p));
let has_config_changes = changed_paths.iter().any(|p| is_config_file(p));
let has_conflicts = check_has_conflicts(repo, base, branch);
Ok(DiffSummary {
files_changed,
lines_added,
lines_removed,
generated_lines_added,
generated_lines_removed,
modules_touched,
sensitive_files: changed_paths, generated_report_artifacts,
has_unsafe,
has_conflicts,
rename_count,
has_migrations,
has_config_changes,
})
}
fn check_has_conflicts(repo: &Path, base: &str, branch: &str) -> bool {
let merge_base = Command::new("git")
.args(["merge-base", base, branch])
.current_dir(repo)
.output();
let merge_base_sha = match merge_base {
Ok(output) if output.status.success() => {
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
_ => return true, };
let result = Command::new("git")
.args(["merge-tree", &merge_base_sha, base, branch])
.current_dir(repo)
.output();
match result {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.contains("<<<<<<") || stdout.contains("changed in both")
}
Err(_) => true, }
}
fn is_migration_file(path: &str) -> bool {
let lower = path.to_lowercase();
lower.contains("migration")
|| lower.contains("migrate")
|| lower.contains("/db/")
|| lower.contains("schema")
|| lower.ends_with(".sql")
}
fn is_generated_data_file(path: &str) -> bool {
let lower = path.to_lowercase();
(lower.contains("generated/") || lower.contains("reference/") || lower.contains("fixtures/"))
&& !lower.starts_with("src/")
}
fn is_generated_report_artifact(path: &str) -> bool {
let lower = path.to_lowercase();
lower.starts_with(".batty/reports/")
|| lower.starts_with(".batty/releases/")
|| lower.starts_with(".batty/retrospectives/")
|| lower.starts_with("reports/")
|| lower.starts_with("coverage/")
|| lower.starts_with("target/")
}
fn is_config_file(path: &str) -> bool {
let lower = path.to_lowercase();
let has_config_ext = lower.ends_with(".yaml")
|| lower.ends_with(".yml")
|| lower.ends_with(".toml")
|| lower.ends_with(".json")
|| lower.ends_with(".env")
|| lower.ends_with(".env.example");
if !has_config_ext {
return false;
}
let is_generated = lower.contains("generated/")
|| lower.contains("reference/")
|| lower.contains("fixtures/")
|| lower.contains("tests/")
|| lower.ends_with(".lock")
|| lower.ends_with("lock.json");
has_config_ext && !is_generated
}
pub fn compute_merge_confidence(summary: &DiffSummary, policy: &AutoMergePolicy) -> f64 {
let mut confidence = 1.0f64;
if summary.files_changed > 3 {
confidence -= 0.1 * (summary.files_changed - 3) as f64;
}
if summary.modules_touched.len() > 1 {
confidence -= 0.2 * (summary.modules_touched.len() - 1) as f64;
}
let touches_sensitive = summary
.sensitive_files
.iter()
.any(|f| policy.sensitive_paths.iter().any(|s| f.contains(s)));
if touches_sensitive {
confidence -= 0.3;
}
if !summary.generated_report_artifacts.is_empty() {
confidence -= 0.3;
}
let total_lines = summary.total_lines();
if total_lines > 100 {
let excess = total_lines - 100;
confidence -= 0.1 * (excess / 50) as f64;
}
if summary.has_unsafe {
confidence -= 0.4;
}
if summary.has_conflicts {
confidence -= 0.5;
}
if summary.has_migrations {
confidence -= 0.3;
}
if summary.has_config_changes {
confidence -= 0.15;
}
if summary.rename_count > 0 && summary.files_changed > 0 {
let rename_ratio = summary.rename_count as f64 / summary.files_changed as f64;
confidence += 0.1 * rename_ratio;
}
confidence.max(0.0)
}
pub fn score_auto_merge_candidate(summary: &DiffSummary, policy: &AutoMergePolicy) -> f64 {
compute_merge_confidence(summary, policy)
}
pub fn evaluate_auto_merge_candidate(
summary: &DiffSummary,
policy: &AutoMergePolicy,
tests_passed: bool,
) -> AutoMergeDecisionRecord {
if !policy.enabled {
return AutoMergeDecisionRecord::from_summary(
Some(summary),
score_auto_merge_candidate(summary, policy),
AutoMergeDecisionKind::ManualReview,
vec!["auto-merge disabled by policy".to_string()],
tests_passed,
None,
);
}
let confidence = score_auto_merge_candidate(summary, policy);
let mut reasons = Vec::new();
if policy.require_tests_pass && !tests_passed {
reasons.push("tests did not pass".to_string());
}
if summary.has_conflicts {
reasons.push("conflicts with main".to_string());
}
if confidence < policy.confidence_threshold {
reasons.push(format!(
"confidence {:.2} below threshold {:.2}",
confidence, policy.confidence_threshold
));
}
if summary.files_changed > policy.max_files_changed {
reasons.push(format!(
"{} files changed (max {})",
summary.files_changed, policy.max_files_changed
));
}
let review_lines = summary.review_lines();
if review_lines > policy.max_diff_lines {
reasons.push(format!(
"{} diff lines (max {})",
review_lines, policy.max_diff_lines
));
}
if summary.modules_touched.len() > policy.max_modules_touched {
reasons.push(format!(
"{} modules touched (max {})",
summary.modules_touched.len(),
policy.max_modules_touched
));
}
let touches_sensitive = summary
.sensitive_files
.iter()
.any(|f| policy.sensitive_paths.iter().any(|s| f.contains(s)));
if touches_sensitive {
reasons.push("touches sensitive paths".to_string());
}
if !summary.generated_report_artifacts.is_empty() {
let paths = summary
.generated_report_artifacts
.iter()
.take(3)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let suffix = if summary.generated_report_artifacts.len() > 3 {
format!(
", and {} more",
summary.generated_report_artifacts.len() - 3
)
} else {
String::new()
};
reasons.push(format!(
"contains generated/report artifacts: {paths}{suffix}"
));
}
if summary.has_unsafe {
reasons.push("contains unsafe blocks".to_string());
}
if summary.has_migrations {
reasons.push("contains migration/schema changes".to_string());
}
if reasons.is_empty() {
AutoMergeDecisionRecord::from_summary(
Some(summary),
confidence,
AutoMergeDecisionKind::Accepted,
vec![format!(
"confidence {:.2} meets threshold {:.2}; diff stays within file/module/line policy limits",
confidence, policy.confidence_threshold
)],
tests_passed,
None,
)
} else {
AutoMergeDecisionRecord::from_summary(
Some(summary),
confidence,
AutoMergeDecisionKind::ManualReview,
reasons,
tests_passed,
None,
)
}
}
pub fn forced_auto_merge_decision(
summary: Option<&DiffSummary>,
policy: &AutoMergePolicy,
tests_passed: bool,
) -> AutoMergeDecisionRecord {
AutoMergeDecisionRecord::from_summary(
summary,
summary.map_or(0.0, |value| score_auto_merge_candidate(value, policy)),
AutoMergeDecisionKind::Accepted,
vec!["auto-merge forced by per-task override".to_string()],
tests_passed,
Some(true),
)
}
pub fn forced_manual_review_decision(
summary: Option<&DiffSummary>,
policy: &AutoMergePolicy,
tests_passed: bool,
) -> AutoMergeDecisionRecord {
AutoMergeDecisionRecord::from_summary(
summary,
summary.map_or(0.0, |value| score_auto_merge_candidate(value, policy)),
AutoMergeDecisionKind::ManualReview,
vec!["auto-merge disabled by per-task override".to_string()],
tests_passed,
Some(false),
)
}
pub fn explain_auto_merge_decision(record: &AutoMergeDecisionRecord) -> String {
let decision = match record.decision {
AutoMergeDecisionKind::Accepted => "accepted for auto-merge",
AutoMergeDecisionKind::ManualReview => "routed to manual review",
};
let override_text = match record.override_forced {
Some(true) => " (forced by override)",
Some(false) => " (disabled by override)",
None => "",
};
let diff_shape = if record.diff_available {
format!(
"{} files, {} lines, {} modules",
record.files_changed, record.lines_changed, record.modules_touched
)
} else {
"diff summary unavailable".to_string()
};
format!(
"{decision}{override_text}: confidence {:.2}; {diff_shape}; reasons: {}",
record.confidence,
record.reasons.join("; ")
)
}
pub fn should_auto_merge(
summary: &DiffSummary,
policy: &AutoMergePolicy,
tests_passed: bool,
) -> AutoMergeDecision {
let record = evaluate_auto_merge_candidate(summary, policy, tests_passed);
match record.decision {
AutoMergeDecisionKind::Accepted => AutoMergeDecision::AutoMerge {
confidence: record.confidence,
},
AutoMergeDecisionKind::ManualReview => AutoMergeDecision::ManualReview {
confidence: record.confidence,
reasons: record.reasons,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_policy() -> AutoMergePolicy {
AutoMergePolicy::default()
}
fn enabled_policy() -> AutoMergePolicy {
AutoMergePolicy {
enabled: true,
..AutoMergePolicy::default()
}
}
fn make_summary(
files: usize,
added: usize,
removed: usize,
modules: Vec<&str>,
sensitive: Vec<&str>,
has_unsafe: bool,
) -> DiffSummary {
DiffSummary {
files_changed: files,
lines_added: added,
lines_removed: removed,
generated_lines_added: 0,
generated_lines_removed: 0,
modules_touched: modules.into_iter().map(String::from).collect(),
sensitive_files: sensitive.into_iter().map(String::from).collect(),
generated_report_artifacts: Vec::new(),
has_unsafe,
has_conflicts: false,
rename_count: 0,
has_migrations: false,
has_config_changes: false,
}
}
#[test]
fn small_clean_diff_auto_merges() {
let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
let policy = enabled_policy();
let decision = should_auto_merge(&summary, &policy, true);
match decision {
AutoMergeDecision::AutoMerge { confidence } => {
assert!(
confidence >= 0.8,
"confidence should be >= 0.8, got {}",
confidence
);
}
other => panic!("expected AutoMerge, got {:?}", other),
}
}
#[test]
fn large_diff_routes_to_review() {
let summary = make_summary(3, 1500, 600, vec!["team"], vec![], false);
let policy = enabled_policy();
let decision = should_auto_merge(&summary, &policy, true);
match decision {
AutoMergeDecision::ManualReview { reasons, .. } => {
assert!(
reasons.iter().any(|r| r.contains("diff lines")),
"should mention diff lines: {:?}",
reasons
);
}
other => panic!("expected ManualReview, got {:?}", other),
}
}
#[test]
fn sensitive_file_routes_to_review() {
let summary = make_summary(2, 20, 10, vec!["team"], vec!["Cargo.toml"], false);
let policy = enabled_policy();
let decision = should_auto_merge(&summary, &policy, true);
match decision {
AutoMergeDecision::ManualReview { reasons, .. } => {
assert!(
reasons.iter().any(|r| r.contains("sensitive")),
"should mention sensitive paths: {:?}",
reasons
);
}
other => panic!("expected ManualReview, got {:?}", other),
}
}
#[test]
fn multi_module_reduces_confidence() {
let summary = make_summary(
4,
40,
10,
vec!["team", "cli", "tmux", "agent"],
vec![],
false,
);
let policy = enabled_policy();
let confidence = compute_merge_confidence(&summary, &policy);
assert!(
confidence < 0.5,
"multi-module diff should have reduced confidence: {}",
confidence,
);
}
#[test]
fn confidence_floor_at_zero() {
let summary = make_summary(
20,
2000,
1000,
vec!["team", "cli", "tmux", "agent", "config"],
vec!["Cargo.toml", ".env"],
true,
);
let policy = enabled_policy();
let confidence = compute_merge_confidence(&summary, &policy);
assert_eq!(confidence, 0.0, "confidence should be floored at 0.0");
}
#[test]
fn disabled_policy_always_manual() {
let summary = make_summary(1, 5, 2, vec!["team"], vec![], false);
let mut policy = default_policy();
policy.enabled = false;
let decision = should_auto_merge(&summary, &policy, true);
match decision {
AutoMergeDecision::ManualReview { reasons, .. } => {
assert!(
reasons.iter().any(|r| r.contains("disabled")),
"should mention disabled: {:?}",
reasons
);
}
other => panic!("expected ManualReview, got {:?}", other),
}
}
#[test]
fn config_deserializes_with_defaults() {
let yaml = "{}";
let policy: AutoMergePolicy = serde_yaml::from_str(yaml).unwrap();
assert!(policy.enabled);
assert_eq!(policy.max_diff_lines, 2000);
assert_eq!(policy.max_files_changed, 30);
assert_eq!(policy.max_modules_touched, 10);
assert_eq!(policy.confidence_threshold, 0.0);
assert!(policy.require_tests_pass);
assert!(policy.post_merge_verify);
assert!(policy.sensitive_paths.contains(&"Cargo.toml".to_string()));
}
#[test]
fn unsafe_blocks_reduce_confidence() {
let summary = make_summary(2, 30, 20, vec!["team"], vec![], true);
let policy = enabled_policy();
let confidence = compute_merge_confidence(&summary, &policy);
assert!(
(confidence - 0.6).abs() < 0.001,
"confidence should be 0.6, got {}",
confidence
);
}
#[test]
fn tests_not_passed_routes_to_review() {
let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
let policy = enabled_policy();
let decision = should_auto_merge(&summary, &policy, false);
match decision {
AutoMergeDecision::ManualReview { reasons, .. } => {
assert!(
reasons.iter().any(|r| r.contains("tests did not pass")),
"should mention tests: {:?}",
reasons
);
}
other => panic!("expected ManualReview, got {:?}", other),
}
}
#[test]
fn tests_not_required_allows_merge_without_passing() {
let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
let mut policy = enabled_policy();
policy.require_tests_pass = false;
let decision = should_auto_merge(&summary, &policy, false);
match decision {
AutoMergeDecision::AutoMerge { .. } => {}
other => panic!(
"expected AutoMerge when tests not required, got {:?}",
other
),
}
}
#[test]
fn conflicts_reduce_confidence_and_route_to_review() {
let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
summary.has_conflicts = true;
let policy = enabled_policy();
let confidence = compute_merge_confidence(&summary, &policy);
assert!(
(confidence - 0.5).abs() < 0.001,
"confidence should be 0.5, got {}",
confidence
);
let decision = should_auto_merge(&summary, &policy, true);
match decision {
AutoMergeDecision::ManualReview { reasons, .. } => {
assert!(
reasons.iter().any(|r| r.contains("conflicts")),
"should mention conflicts: {:?}",
reasons
);
}
other => panic!("expected ManualReview, got {:?}", other),
}
}
#[test]
fn migrations_reduce_confidence() {
let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
summary.has_migrations = true;
let policy = enabled_policy();
let confidence = compute_merge_confidence(&summary, &policy);
assert!(
(confidence - 0.7).abs() < 0.001,
"confidence should be 0.7, got {}",
confidence
);
}
#[test]
fn migrations_route_to_review() {
let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
summary.has_migrations = true;
let policy = enabled_policy();
let decision = should_auto_merge(&summary, &policy, true);
match decision {
AutoMergeDecision::ManualReview { reasons, .. } => {
assert!(
reasons.iter().any(|r| r.contains("migration")),
"should mention migration: {:?}",
reasons
);
}
other => panic!("expected ManualReview, got {:?}", other),
}
}
#[test]
fn config_changes_reduce_confidence() {
let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
summary.has_config_changes = true;
let policy = enabled_policy();
let confidence = compute_merge_confidence(&summary, &policy);
assert!(
(confidence - 0.85).abs() < 0.001,
"confidence should be 0.85, got {}",
confidence
);
}
#[test]
fn config_changes_auto_merge_when_confidence_above_threshold() {
let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
summary.has_config_changes = true;
let policy = enabled_policy();
let decision = should_auto_merge(&summary, &policy, true);
match decision {
AutoMergeDecision::AutoMerge { .. } => {} other => panic!("config-only change should auto-merge, got {:?}", other),
}
}
#[test]
fn renames_boost_confidence() {
let mut summary = make_summary(4, 10, 10, vec!["team"], vec![], false);
summary.rename_count = 3;
let policy = enabled_policy();
let confidence_with_renames = compute_merge_confidence(&summary, &policy);
let summary_no_renames = make_summary(4, 10, 10, vec!["team"], vec![], false);
let confidence_without = compute_merge_confidence(&summary_no_renames, &policy);
assert!(
confidence_with_renames > confidence_without,
"renames should boost confidence: with={}, without={}",
confidence_with_renames,
confidence_without
);
}
#[test]
fn all_renames_gives_full_boost() {
let mut summary = make_summary(4, 0, 0, vec!["team"], vec![], false);
summary.rename_count = 4;
let policy = enabled_policy();
let confidence = compute_merge_confidence(&summary, &policy);
assert!(
(confidence - 1.0).abs() < 0.001,
"all-rename diff should have full confidence: {}",
confidence
);
}
#[test]
fn heterogeneous_but_bounded_diff_still_auto_merges() {
let summary = make_summary(3, 45, 15, vec!["team", "metrics"], vec![], false);
let policy = enabled_policy();
let record = evaluate_auto_merge_candidate(&summary, &policy, true);
assert_eq!(record.decision, AutoMergeDecisionKind::Accepted);
assert!(
record.reasons[0].contains("meets threshold"),
"should contain acceptance reason: {:?}",
record.reasons
);
}
#[test]
fn forced_override_decision_is_explicit() {
let summary = make_summary(5, 80, 20, vec!["team", "metrics", "daemon"], vec![], false);
let policy = enabled_policy();
let record = forced_auto_merge_decision(Some(&summary), &policy, true);
assert_eq!(record.decision, AutoMergeDecisionKind::Accepted);
assert_eq!(record.override_forced, Some(true));
assert_eq!(
explain_auto_merge_decision(&record),
"accepted for auto-merge (forced by override): confidence 0.40; 5 files, 100 lines, 3 modules; reasons: auto-merge forced by per-task override"
);
}
#[test]
fn migration_file_detection() {
assert!(is_migration_file("db/migrate/001_add_users.sql"));
assert!(is_migration_file("src/migrations/v2.rs"));
assert!(is_migration_file("schema.sql"));
assert!(!is_migration_file("src/team/mod.rs"));
}
#[test]
fn config_file_detection() {
assert!(is_config_file("team.yaml"));
assert!(is_config_file("Cargo.toml"));
assert!(is_config_file("package.json"));
assert!(is_config_file(".env"));
assert!(!is_config_file("src/team/config.rs"));
}
#[test]
fn generated_data_diff_does_not_trip_line_count_gate() {
let mut summary = make_summary(1, 39035, 0, vec![], vec!["generated/catalog.json"], false);
summary.generated_lines_added = 39035;
let policy = enabled_policy();
let decision = should_auto_merge(&summary, &policy, true);
match decision {
AutoMergeDecision::AutoMerge { .. } => {}
other => panic!(
"generated data extraction should auto-merge, got {:?}",
other
),
}
}
#[test]
fn generated_report_artifacts_route_to_review() {
let mut summary = make_summary(
2,
20,
0,
vec!["team"],
vec!["src/team/merge/completion.rs"],
false,
);
summary.generated_report_artifacts =
vec![".batty/reports/verification/completion/task-042.json".to_string()];
let policy = enabled_policy();
let decision = should_auto_merge(&summary, &policy, true);
match decision {
AutoMergeDecision::ManualReview { reasons, .. } => {
assert!(
reasons
.iter()
.any(|reason| reason.contains("generated/report artifacts")),
"should mention generated/report artifacts: {:?}",
reasons
);
}
other => panic!(
"generated/report artifacts should route to manual review, got {:?}",
other
),
}
}
#[test]
fn source_diff_still_trips_line_count_gate() {
let summary = make_summary(1, 2500, 0, vec!["team"], vec!["src/team/catalog.rs"], false);
let policy = enabled_policy();
let decision = should_auto_merge(&summary, &policy, true);
match decision {
AutoMergeDecision::ManualReview { reasons, .. } => {
assert!(
reasons.iter().any(|r| r.contains("2500 diff lines")),
"should mention gated source diff lines: {:?}",
reasons
);
}
other => panic!(
"large source diff should route to manual review, got {:?}",
other
),
}
}
#[test]
fn combined_risk_factors_accumulate() {
let mut summary = make_summary(
6,
200,
100,
vec!["team", "cli", "tmux"],
vec!["Cargo.toml"],
true,
);
summary.has_migrations = true;
summary.has_config_changes = true;
summary.has_conflicts = true;
let policy = enabled_policy();
let confidence = compute_merge_confidence(&summary, &policy);
assert_eq!(confidence, 0.0, "extreme risk diff should floor at 0.0");
}
#[test]
fn override_persistence_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".batty")).unwrap();
assert!(load_overrides(root).is_empty());
save_override(root, 42, true).unwrap();
let overrides = load_overrides(root);
assert_eq!(overrides.get(&42), Some(&true));
save_override(root, 99, false).unwrap();
let overrides = load_overrides(root);
assert_eq!(overrides.get(&42), Some(&true));
assert_eq!(overrides.get(&99), Some(&false));
save_override(root, 42, false).unwrap();
let overrides = load_overrides(root);
assert_eq!(overrides.get(&42), Some(&false));
}
#[test]
fn load_overrides_malformed_json_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let path = root.join(OVERRIDES_FILE);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "not valid json {{{}").unwrap();
assert!(load_overrides(root).is_empty());
}
#[test]
fn load_overrides_wrong_json_type_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let path = root.join(OVERRIDES_FILE);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "[1, 2, 3]").unwrap();
assert!(load_overrides(root).is_empty());
}
#[test]
fn save_override_creates_batty_dir() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
save_override(root, 1, true).unwrap();
assert!(root.join(OVERRIDES_FILE).exists());
}
#[test]
fn save_override_to_readonly_dir_returns_error() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let batty_dir = root.join(".batty");
std::fs::create_dir(&batty_dir).unwrap();
std::fs::set_permissions(&batty_dir, std::fs::Permissions::from_mode(0o444)).unwrap();
let result = save_override(root, 1, true);
assert!(result.is_err());
std::fs::set_permissions(&batty_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
#[test]
fn analyze_diff_on_non_git_dir_returns_empty_summary() {
let tmp = tempfile::tempdir().unwrap();
let result = analyze_diff(tmp.path(), "main", "feature");
if let Ok(summary) = result {
assert_eq!(summary.files_changed, 0);
assert_eq!(summary.total_lines(), 0);
}
}
}