Skip to main content

suture_core/dag/
merge.rs

1//! DAG-aware merge operations.
2//!
3//! Extends the patch-level merge algorithm with DAG-aware operations:
4//! - Finding common ancestors between branches
5//! - Computing merge bases
6//! - Generating merge plans
7
8use crate::dag::graph::{DagError, PatchDag};
9use crate::patch::merge::{self, MergeResult};
10use crate::patch::types::PatchId;
11use std::collections::HashMap;
12use suture_common::BranchName;
13
14impl PatchDag {
15    /// Compute a merge plan for two branches.
16    ///
17    /// Returns a `MergeResult` containing the patches to merge and any conflicts.
18    /// This does NOT modify the DAG — it only computes the plan.
19    pub fn merge_branches(
20        &self,
21        branch_a: &BranchName,
22        branch_b: &BranchName,
23    ) -> Result<MergeResult, DagError> {
24        let target_a = self
25            .get_branch(branch_a)
26            .ok_or_else(|| DagError::BranchNotFound(branch_a.as_str().to_string()))?;
27        let target_b = self
28            .get_branch(branch_b)
29            .ok_or_else(|| DagError::BranchNotFound(branch_b.as_str().to_string()))?;
30
31        // Find LCA
32        let lca_id = self
33            .lca(&target_a, &target_b)
34            .ok_or_else(|| DagError::Custom("no common ancestor found".to_string()))?;
35
36        // Get patch chains from LCA to each branch tip
37        let chain_a = self.patch_chain(&target_a);
38        let chain_b = self.patch_chain(&target_b);
39
40        // Find the LCA position in each chain
41        let lca_pos_a = chain_a.iter().position(|id| *id == lca_id);
42        let lca_pos_b = chain_b.iter().position(|id| *id == lca_id);
43
44        // Extract patches unique to each branch (after LCA)
45        let branch_a_patches: Vec<PatchId> = match lca_pos_a {
46            Some(pos) => chain_a[..pos].to_vec(), // Everything before LCA in chain
47            None => chain_a.clone(),
48        };
49
50        let branch_b_patches: Vec<PatchId> = match lca_pos_b {
51            Some(pos) => chain_b[..pos].to_vec(),
52            None => chain_b.clone(),
53        };
54
55        // Build patch lookup
56        let all_patches: HashMap<PatchId, _> = self
57            .nodes
58            .iter()
59            .map(|(id, node)| (*id, node.patch.clone()))
60            .collect();
61
62        // Use the patch-level merge algorithm
63        merge::merge(
64            &[lca_id],
65            &branch_a_patches,
66            &branch_b_patches,
67            &all_patches,
68        )
69        .map_err(|e| DagError::Custom(e.to_string()))
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use crate::dag::graph::PatchDag;
76    use crate::patch::types::{OperationType, Patch, TouchSet};
77    use suture_common::BranchName;
78
79    fn make_patch(addr: &str) -> Patch {
80        Patch::new(
81            OperationType::Modify,
82            TouchSet::single(addr),
83            Some(format!("file_{}", addr)),
84            vec![],
85            vec![],
86            "test".to_string(),
87            format!("edit {}", addr),
88        )
89    }
90
91    #[test]
92    fn test_merge_clean_divergent_branches() {
93        let mut dag = PatchDag::new();
94
95        // Root commit
96        let root = make_patch("root");
97        let root_id = dag.add_patch(root, vec![]).unwrap();
98
99        // Verify DAG structure
100        assert!(dag.has_patch(&root_id), "root should be in DAG");
101
102        // Branch A: edit A1
103        let a1 = make_patch("A1");
104        let a1_id = dag.add_patch(a1, vec![root_id]).unwrap();
105        assert!(dag.has_patch(&a1_id), "a1 should be in DAG");
106
107        // Branch B: edit B1
108        let b1 = make_patch("B1");
109        let b1_id = dag.add_patch(b1, vec![root_id]).unwrap();
110        assert!(dag.has_patch(&b1_id), "b1 should be in DAG");
111
112        // Verify ancestors
113        let anc_a = dag.ancestors(&a1_id);
114        let anc_b = dag.ancestors(&b1_id);
115        assert!(anc_a.contains(&root_id), "root should be ancestor of a1");
116        assert!(anc_b.contains(&root_id), "root should be ancestor of b1");
117
118        // Verify LCA via ancestors intersection
119        let intersection: std::collections::HashSet<_> = anc_a.intersection(&anc_b).collect();
120        assert_eq!(intersection.len(), 1, "should have 1 common ancestor");
121        assert!(
122            intersection.contains(&root_id),
123            "common ancestor should be root_id"
124        );
125
126        // Direct LCA test
127        let direct_lca = dag.lca(&a1_id, &b1_id);
128        assert_eq!(direct_lca, Some(root_id), "LCA of a1 and b1 should be root");
129
130        let branch_a = BranchName::new("branch_a").unwrap();
131        let branch_b = BranchName::new("branch_b").unwrap();
132
133        dag.create_branch(branch_a.clone(), a1_id).unwrap();
134        dag.create_branch(branch_b.clone(), b1_id).unwrap();
135
136        let result = dag.merge_branches(&branch_a, &branch_b).unwrap();
137        assert!(result.is_clean);
138        assert!(result.conflicts.is_empty());
139    }
140
141    #[test]
142    fn test_merge_conflicting_branches() {
143        let mut dag = PatchDag::new();
144
145        let root = make_patch("root");
146        let root_id = dag.add_patch(root, vec![]).unwrap();
147
148        // Both branches edit the SAME address "shared_addr" but with different payloads
149        // so they get different patch IDs but still conflict on the touch set.
150        let a1 = Patch::new(
151            OperationType::Modify,
152            TouchSet::from_addrs(["shared_addr"]),
153            Some("file_shared_addr".to_string()),
154            vec![1], // payload A
155            vec![],
156            "alice".to_string(),
157            "edit shared_addr (version A)".to_string(),
158        );
159        let a1_id = dag.add_patch(a1, vec![root_id]).unwrap();
160
161        let b1 = Patch::new(
162            OperationType::Modify,
163            TouchSet::from_addrs(["shared_addr"]),
164            Some("file_shared_addr".to_string()),
165            vec![2], // payload B (different!)
166            vec![],
167            "bob".to_string(),
168            "edit shared_addr (version B)".to_string(),
169        );
170        let b1_id = dag.add_patch(b1, vec![root_id]).unwrap();
171
172        let branch_a = BranchName::new("branch_a").unwrap();
173        let branch_b = BranchName::new("branch_b").unwrap();
174
175        dag.create_branch(branch_a.clone(), a1_id).unwrap();
176        dag.create_branch(branch_b.clone(), b1_id).unwrap();
177
178        let result = dag.merge_branches(&branch_a, &branch_b).unwrap();
179        assert!(
180            !result.is_clean,
181            "branches editing the same address should conflict"
182        );
183        assert_eq!(result.conflicts.len(), 1, "should have exactly 1 conflict");
184    }
185}