use crate::{
Config, MergePlan, MergeStep, MergeOrderStrategy, ConflictPolicy, GitRepo,
Error, Result
};
use crate::party_resolver::PartyResolver;
use tracing::{debug, info, warn};
pub struct MergePlanner<'a> {
config: &'a Config,
git_repo: &'a GitRepo,
}
impl<'a> MergePlanner<'a> {
pub fn new(config: &'a Config, git_repo: &'a GitRepo) -> Self {
Self { config, git_repo }
}
pub fn create_plan(&self, party_name: &str) -> Result<MergePlan> {
info!("Creating merge plan for party: {}", party_name);
let resolver = PartyResolver::new(self.config);
let resolved = resolver.resolve_party(party_name)?;
let party_config = self.config.parties.get(party_name)
.ok_or_else(|| Error::party_not_found(party_name))?;
let ordered_branches = self.order_branches(&resolved.branches, &party_config.merge_order)?;
let destination = format!("party/{}", party_name);
let mut plan = MergePlan::new(
party_name.to_string(),
destination,
self.config.base_branch.clone(),
ordered_branches.clone(),
party_config.merge_order.clone(),
party_config.conflict_policy.clone(),
);
for (i, source_branch) in ordered_branches.iter().enumerate() {
let target_branch = if i == 0 {
self.config.base_branch.clone()
} else {
plan.destination.clone()
};
let mut step = MergeStep::new(source_branch.clone(), target_branch.clone());
if let Ok(analysis) = self.analyze_merge(source_branch, &target_branch) {
if analysis.is_fast_forward {
step = step.as_fast_forward();
}
if !analysis.conflicted_files.is_empty() {
step = step.with_conflicts(analysis.conflicted_files);
}
}
plan.add_step(step);
}
info!("Created merge plan with {} steps", plan.steps.len());
debug!("Plan details: {} sources, {} estimated conflicts, {} fast-forwards",
plan.total_sources(), plan.estimated_conflicts(), plan.fast_forward_count());
Ok(plan)
}
fn order_branches(&self, branches: &[String], strategy: &MergeOrderStrategy) -> Result<Vec<String>> {
match strategy {
MergeOrderStrategy::Listed => {
Ok(branches.to_vec())
}
MergeOrderStrategy::NewestFirst => {
self.order_by_commit_time(branches, true)
}
MergeOrderStrategy::OldestFirst => {
self.order_by_commit_time(branches, false)
}
}
}
fn order_by_commit_time(&self, branches: &[String], newest_first: bool) -> Result<Vec<String>> {
let mut branch_times = Vec::new();
for branch in branches {
match self.git_repo.get_commit_id(branch) {
Ok(commit_id) => {
branch_times.push((branch.clone(), commit_id));
}
Err(_) => {
warn!("Could not get commit info for branch: {}", branch);
branch_times.push((branch.clone(), String::new()));
}
}
}
branch_times.sort_by(|a, b| {
if newest_first {
b.1.cmp(&a.1)
} else {
a.1.cmp(&b.1)
}
});
Ok(branch_times.into_iter().map(|(branch, _)| branch).collect())
}
pub fn create_plan_from_branches(
&self,
base_branch: &str,
branches: &[String],
strategy: MergeOrderStrategy,
conflict_policy: ConflictPolicy,
) -> Result<MergePlan> {
info!("Creating merge plan from explicit branch list: {:?}", branches);
let ordered_branches = self.order_branches(branches, &strategy)?;
let destination = format!("party/temp-{}", uuid::Uuid::new_v4().to_string()[..8].to_string());
let mut plan = MergePlan::new(
"temp".to_string(),
destination,
base_branch.to_string(),
ordered_branches.clone(),
strategy,
conflict_policy,
);
for (i, source_branch) in ordered_branches.iter().enumerate() {
let target_branch = if i == 0 {
base_branch.to_string()
} else {
plan.destination.clone()
};
let mut step = MergeStep::new(source_branch.clone(), target_branch.clone());
if let Ok(analysis) = self.analyze_merge(source_branch, &target_branch) {
if analysis.is_fast_forward {
step = step.as_fast_forward();
}
if !analysis.conflicted_files.is_empty() {
step = step.with_conflicts(analysis.conflicted_files);
}
}
plan.add_step(step);
}
Ok(plan)
}
fn analyze_merge(&self, source: &str, target: &str) -> Result<MergeAnalysis> {
debug!("Analyzing merge: {} -> {}", source, target);
Ok(MergeAnalysis {
is_fast_forward: false,
conflicted_files: Vec::new(),
merge_base: None,
})
}
pub fn validate_plan(&self, plan: &MergePlan) -> Result<()> {
if !self.git_repo.branch_exists(&plan.base_branch)? {
return Err(Error::branch_not_found(&plan.base_branch));
}
for source in &plan.sources {
if !self.git_repo.branch_exists(source)? {
return Err(Error::branch_not_found(source));
}
}
Ok(())
}
pub fn get_plan_stats(&self, plan: &MergePlan) -> PlanStats {
PlanStats {
total_merges: plan.steps.len(),
fast_forwards: plan.fast_forward_count(),
predicted_conflicts: plan.estimated_conflicts(),
source_branches: plan.sources.len(),
}
}
}
#[derive(Debug)]
struct MergeAnalysis {
is_fast_forward: bool,
conflicted_files: Vec<String>,
#[allow(dead_code)] merge_base: Option<String>,
}
#[derive(Debug)]
pub struct PlanStats {
pub total_merges: usize,
pub fast_forwards: usize,
pub predicted_conflicts: usize,
pub source_branches: usize,
}
impl PlanStats {
pub fn complexity_score(&self) -> f64 {
let base_score = self.total_merges as f64;
let conflict_penalty = self.predicted_conflicts as f64 * 2.0;
let fast_forward_bonus = self.fast_forwards as f64 * -0.5;
base_score + conflict_penalty + fast_forward_bonus
}
}
pub fn format_plan(plan: &MergePlan, verbose: bool) -> String {
let mut output = String::new();
output.push_str(&format!("Merge Plan for party '{}'\n", plan.party_name));
output.push_str(&format!("Destination: {}\n", plan.destination));
output.push_str(&format!("Base: {}\n", plan.base_branch));
output.push_str(&format!("Strategy: {}\n", plan.strategy));
output.push_str(&format!("Sources: {} branches\n\n", plan.sources.len()));
if verbose {
output.push_str("Merge Steps:\n");
for (i, step) in plan.steps.iter().enumerate() {
let step_type = if step.is_fast_forward {
"[FF]"
} else if !step.expected_conflicts.is_empty() {
"[CONFLICT]"
} else {
"[MERGE]"
};
output.push_str(&format!(" {}. {} {} -> {}\n",
i + 1, step_type, step.source, step.target));
if !step.expected_conflicts.is_empty() {
output.push_str(&format!(" Potential conflicts: {}\n",
step.expected_conflicts.join(", ")));
}
}
} else {
let stats = PlanStats {
total_merges: plan.steps.len(),
fast_forwards: plan.fast_forward_count(),
predicted_conflicts: plan.estimated_conflicts(),
source_branches: plan.sources.len(),
};
output.push_str(&format!("Summary:\n"));
output.push_str(&format!(" {} merge steps\n", stats.total_merges));
output.push_str(&format!(" {} fast-forwards\n", stats.fast_forwards));
output.push_str(&format!(" {} predicted conflicts\n", stats.predicted_conflicts));
output.push_str(&format!(" Complexity score: {:.1}\n", stats.complexity_score()));
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Config, Party, AutoUpdateConfig};
use std::collections::HashMap;
use tempfile::TempDir;
fn create_test_config() -> Config {
let mut parties = HashMap::new();
parties.insert("qa".to_string(), Party {
members: vec!["feature/a".to_string(), "feature/b".to_string()],
merge_order: MergeOrderStrategy::Listed,
conflict_policy: ConflictPolicy::default(),
});
Config {
base_branch: "main".to_string(),
parties: HashMap::new(),
auto_update: AutoUpdateConfig::default(),
}
}
#[test]
fn test_plan_creation() {
let config = create_test_config();
let temp_dir = TempDir::new().unwrap();
let repo = git2::Repository::init(temp_dir.path()).unwrap();
let git_repo = GitRepo::open(temp_dir.path()).unwrap();
let planner = MergePlanner::new(&config, &git_repo);
assert!(planner.config.parties.contains_key("qa"));
}
#[test]
fn test_branch_ordering() {
let config = create_test_config();
let temp_dir = TempDir::new().unwrap();
let repo = git2::Repository::init(temp_dir.path()).unwrap();
let git_repo = GitRepo::open(temp_dir.path()).unwrap();
let planner = MergePlanner::new(&config, &git_repo);
let branches = vec!["feature/a".to_string(), "feature/b".to_string()];
let ordered = planner.order_branches(&branches, &MergeOrderStrategy::Listed).unwrap();
assert_eq!(ordered, branches);
}
#[test]
fn test_plan_stats() {
let plan = MergePlan::new(
"test".to_string(),
"party/test".to_string(),
"main".to_string(),
vec!["feature/a".to_string(), "feature/b".to_string()],
MergeOrderStrategy::Listed,
ConflictPolicy::default(),
);
let stats = PlanStats {
total_merges: 2,
fast_forwards: 1,
predicted_conflicts: 0,
source_branches: 2,
};
assert_eq!(stats.complexity_score(), 1.5); }
}