Skip to main content

lex_vcs/
merge.rs

1//! Op-DAG three-way merge.
2//!
3//! 1. Compute LCA of src and dst heads.
4//! 2. Get ops on each side since the LCA.
5//! 3. Group by the `SigId` they touch; classify each group.
6
7use crate::op_log::OpLog;
8use crate::operation::{OpId, OperationKind, OperationRecord, SigId, StageId};
9use std::collections::{BTreeMap, BTreeSet};
10use std::io;
11
12#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13#[serde(tag = "outcome", rename_all = "snake_case")]
14pub enum MergeOutcome {
15    /// Both sides converged on the same op_id for this sig.
16    Both { sig_id: SigId, stage_id: Option<StageId> },
17    /// Only src touched it.
18    Src  { sig_id: SigId, stage_id: Option<StageId> },
19    /// Only dst touched it.
20    Dst  { sig_id: SigId, stage_id: Option<StageId> },
21    /// Conflict: both sides touched it with different ops.
22    Conflict {
23        sig_id: SigId,
24        kind: ConflictKind,
25        base: Option<StageId>,
26        src:  Option<StageId>,
27        dst:  Option<StageId>,
28    },
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum ConflictKind {
34    ModifyModify,
35    ModifyDelete,
36    DeleteModify,
37    AddAdd,
38}
39
40#[derive(Debug)]
41pub struct MergeOutput {
42    pub lca: Option<OpId>,
43    pub outcomes: Vec<MergeOutcome>,
44}
45
46pub fn merge(
47    op_log: &OpLog,
48    src_head: Option<&OpId>,
49    dst_head: Option<&OpId>,
50) -> io::Result<MergeOutput> {
51    let lca = match (src_head, dst_head) {
52        (Some(s), Some(d)) => op_log.lca(s, d)?,
53        _ => None,
54    };
55    let src_ops = match src_head {
56        Some(h) => op_log.ops_since(h, lca.as_ref())?,
57        None => Vec::new(),
58    };
59    let dst_ops = match dst_head {
60        Some(h) => op_log.ops_since(h, lca.as_ref())?,
61        None => Vec::new(),
62    };
63
64    let src_by_sig = group_by_sig(&src_ops);
65    let dst_by_sig = group_by_sig(&dst_ops);
66
67    let lca_head: BTreeMap<SigId, StageId> = match lca.as_ref() {
68        Some(id) => head_at(op_log, id)?,
69        None => BTreeMap::new(),
70    };
71
72    let mut outcomes = Vec::new();
73    let sigs: BTreeSet<&SigId> = src_by_sig.keys().chain(dst_by_sig.keys()).collect();
74    for sig in sigs {
75        let s = src_by_sig.get(sig);
76        let d = dst_by_sig.get(sig);
77        let s_stage = s.map(|recs| latest_stage(sig, recs));
78        let d_stage = d.map(|recs| latest_stage(sig, recs));
79        match (s, d) {
80            (Some(s_recs), Some(d_recs)) => {
81                let s_last = s_recs.last().map(|r| r.op_id.as_str()).unwrap_or("");
82                let d_last = d_recs.last().map(|r| r.op_id.as_str()).unwrap_or("");
83                if s_last == d_last {
84                    outcomes.push(MergeOutcome::Both {
85                        sig_id: sig.clone(),
86                        stage_id: s_stage.unwrap(),
87                    });
88                } else {
89                    let kind = classify(&s_stage.clone().unwrap(), &d_stage.clone().unwrap(), &lca_head, sig);
90                    outcomes.push(MergeOutcome::Conflict {
91                        sig_id: sig.clone(),
92                        kind,
93                        base: lca_head.get(sig).cloned(),
94                        src:  s_stage.unwrap(),
95                        dst:  d_stage.unwrap(),
96                    });
97                }
98            }
99            (Some(_), None) => {
100                outcomes.push(MergeOutcome::Src {
101                    sig_id: sig.clone(),
102                    stage_id: s_stage.unwrap(),
103                });
104            }
105            (None, Some(_)) => {
106                outcomes.push(MergeOutcome::Dst {
107                    sig_id: sig.clone(),
108                    stage_id: d_stage.unwrap(),
109                });
110            }
111            (None, None) => unreachable!(),
112        }
113    }
114
115    Ok(MergeOutput { lca, outcomes })
116}
117
118fn group_by_sig(ops: &[OperationRecord]) -> BTreeMap<SigId, Vec<&OperationRecord>> {
119    let mut out: BTreeMap<SigId, Vec<&OperationRecord>> = BTreeMap::new();
120    for r in ops {
121        for sig in touched_sigs(&r.op.kind) {
122            out.entry(sig).or_default().push(r);
123        }
124    }
125    // ops_since returned newest-first; reverse to oldest-first per sig
126    // so `latest_stage` reads the right entry.
127    for v in out.values_mut() { v.reverse(); }
128    out
129}
130
131fn touched_sigs(k: &OperationKind) -> Vec<SigId> {
132    match k {
133        OperationKind::AddFunction { sig_id, .. }
134        | OperationKind::RemoveFunction { sig_id, .. }
135        | OperationKind::ModifyBody { sig_id, .. }
136        | OperationKind::ChangeEffectSig { sig_id, .. }
137        | OperationKind::AddType { sig_id, .. }
138        | OperationKind::RemoveType { sig_id, .. }
139        | OperationKind::ModifyType { sig_id, .. }
140        | OperationKind::ReplaceMatchArm { sig_id, .. }
141        | OperationKind::RenameLocal { sig_id, .. }
142        | OperationKind::InlineLet { sig_id, .. }
143        | OperationKind::Promote { sig_id, .. } => vec![sig_id.clone()],
144        // A rename touches both sides — concurrent modifies on `from`
145        // must surface as a conflict, not as a disjoint set.
146        OperationKind::RenameSymbol { from, to, .. } => vec![from.clone(), to.clone()],
147        OperationKind::AddImport { .. }
148        | OperationKind::RemoveImport { .. }
149        | OperationKind::Merge { .. } => Vec::new(),
150        // Candidates don't advance branch heads; they're
151        // proposals, not commitments. Treat as merge-neutral so
152        // a sig's bake-off doesn't interact with concurrent
153        // merges on other branches.
154        OperationKind::Candidate { .. } => Vec::new(),
155    }
156}
157
158/// Given a chronological (oldest-first) list of ops on a sig, return
159/// the resulting stage_id (`None` if the sig was removed).
160///
161/// The `sig` parameter is used to distinguish the `from` and `to`
162/// sides of a `RenameSymbol` operation: from the `from` sig's
163/// perspective the rename removes it; from the `to` sig's perspective
164/// it produces `body_stage_id`.
165fn latest_stage(sig: &SigId, recs: &[&OperationRecord]) -> Option<StageId> {
166    use crate::operation::{OperationKind as OK, StageTransition::*};
167    let mut current: Option<StageId> = None;
168    for r in recs {
169        // For renames: distinguish which side of the rename we're on.
170        if let OK::RenameSymbol { from, to, body_stage_id } = &r.op.kind {
171            if sig == from {
172                // From this sig's perspective, the rename removed it.
173                current = None;
174            } else if sig == to {
175                current = Some(body_stage_id.clone());
176            }
177            continue;
178        }
179        match &r.produces {
180            Create { stage_id, .. } => current = Some(stage_id.clone()),
181            Replace { to, .. } => current = Some(to.clone()),
182            Remove { .. } => current = None,
183            Rename { body_stage_id, .. } => current = Some(body_stage_id.clone()),
184            ImportOnly | Merge { .. } => {}
185        }
186    }
187    current
188}
189
190fn head_at(op_log: &OpLog, head: &OpId) -> io::Result<BTreeMap<SigId, StageId>> {
191    let mut map = BTreeMap::new();
192    for r in op_log.walk_forward(head, None)? {
193        use crate::operation::StageTransition::*;
194        match &r.produces {
195            Create { sig_id, stage_id } => { map.insert(sig_id.clone(), stage_id.clone()); }
196            Replace { sig_id, to, .. } => { map.insert(sig_id.clone(), to.clone()); }
197            Remove { sig_id, .. } => { map.remove(sig_id); }
198            Rename { from, to, body_stage_id } => {
199                map.remove(from);
200                map.insert(to.clone(), body_stage_id.clone());
201            }
202            ImportOnly => {}
203            Merge { entries } => {
204                for (sig, stage) in entries {
205                    match stage {
206                        Some(s) => { map.insert(sig.clone(), s.clone()); }
207                        None    => { map.remove(sig); }
208                    }
209                }
210            }
211        }
212    }
213    Ok(map)
214}
215
216fn classify(
217    src: &Option<StageId>,
218    dst: &Option<StageId>,
219    base: &BTreeMap<SigId, StageId>,
220    sig: &SigId,
221) -> ConflictKind {
222    let in_base = base.contains_key(sig);
223    match (in_base, src.is_some(), dst.is_some()) {
224        (false, true, true)  => ConflictKind::AddAdd,
225        (true,  true, true)  => ConflictKind::ModifyModify,
226        (true,  true, false) => ConflictKind::ModifyDelete,
227        (true,  false, true) => ConflictKind::DeleteModify,
228        // Other combos shouldn't happen for a "both touched" group:
229        // both sides touched the sig, so at least one should have a
230        // result, and the (false, false, false) / (true, false, false)
231        // shapes are unreachable. Surface as a panic in debug builds
232        // so future invariant violations are loud, not silent.
233        other => {
234            debug_assert!(false, "classify: unreachable shape {other:?} for sig {sig}");
235            ConflictKind::ModifyModify
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::apply::apply;
244    use crate::operation::{Operation, OperationKind, StageTransition};
245    use std::collections::BTreeSet;
246
247    fn fresh() -> (OpLog, tempfile::TempDir) {
248        let tmp = tempfile::tempdir().unwrap();
249        (OpLog::open(tmp.path()).unwrap(), tmp)
250    }
251
252    fn add_fn(log: &OpLog, parent: Option<&OpId>, sig: &str, stg: &str) -> OpId {
253        let op = Operation::new(
254            OperationKind::AddFunction {
255                sig_id: sig.into(),
256                stage_id: stg.into(),
257                effects: BTreeSet::new(),
258                budget_cost: None,
259            },
260            parent.cloned().into_iter().collect::<Vec<_>>(),
261        );
262        let t = StageTransition::Create { sig_id: sig.into(), stage_id: stg.into() };
263        apply(log, parent, op, t).unwrap().op_id
264    }
265
266    fn modify_body(log: &OpLog, parent: &OpId, sig: &str, from: &str, to: &str) -> OpId {
267        let op = Operation::new(
268            OperationKind::ModifyBody {
269                sig_id: sig.into(),
270                from_stage_id: from.into(),
271                to_stage_id: to.into(),
272                from_budget: None,
273                to_budget: None,
274            },
275            [parent.clone()],
276        );
277        let t = StageTransition::Replace {
278            sig_id: sig.into(), from: from.into(), to: to.into(),
279        };
280        apply(log, Some(parent), op, t).unwrap().op_id
281    }
282
283    #[test]
284    fn disjoint_sigs_merge_cleanly() {
285        let (log, _tmp) = fresh();
286        let root = add_fn(&log, None, "shared", "s0");
287        let s_only = add_fn(&log, Some(&root), "src-only", "src1");
288        let d_only = add_fn(&log, Some(&root), "dst-only", "dst1");
289
290        let out = merge(&log, Some(&s_only), Some(&d_only)).unwrap();
291        assert_eq!(out.lca.as_ref(), Some(&root));
292        let kinds: Vec<&str> = out.outcomes.iter().map(|o| match o {
293            MergeOutcome::Src { .. } => "src",
294            MergeOutcome::Dst { .. } => "dst",
295            MergeOutcome::Both { .. } => "both",
296            MergeOutcome::Conflict { .. } => "conflict",
297        }).collect();
298        assert!(kinds.contains(&"src") && kinds.contains(&"dst"));
299        assert!(!kinds.contains(&"conflict"));
300    }
301
302    #[test]
303    fn same_sig_divergent_is_modify_modify_conflict() {
304        let (log, _tmp) = fresh();
305        let root = add_fn(&log, None, "fac", "s0");
306        let src  = modify_body(&log, &root, "fac", "s0", "s-src");
307        let dst  = modify_body(&log, &root, "fac", "s0", "s-dst");
308
309        let out = merge(&log, Some(&src), Some(&dst)).unwrap();
310        let conflict = out.outcomes.iter().find(|o| matches!(o, MergeOutcome::Conflict { .. }));
311        assert!(conflict.is_some());
312        if let Some(MergeOutcome::Conflict { kind, .. }) = conflict {
313            assert!(matches!(kind, ConflictKind::ModifyModify));
314        }
315    }
316
317    #[test]
318    fn independent_histories_no_lca() {
319        let (log, _tmp) = fresh();
320        let a = add_fn(&log, None, "a", "sa");
321        let b = add_fn(&log, None, "b", "sb");
322        let out = merge(&log, Some(&a), Some(&b)).unwrap();
323        assert!(out.lca.is_none());
324    }
325
326    #[test]
327    fn rename_on_src_with_concurrent_modify_on_dst_conflicts() {
328        // src renames fac → fac2 (same body). dst modifies fac's body.
329        // The merge must surface a conflict on `fac` (modify-delete from
330        // dst's perspective: dst modified, src "removed" via rename),
331        // not silently report disjoint outcomes that lose dst's change.
332        let (log, _tmp) = fresh();
333        let root = add_fn(&log, None, "fac", "s0");
334
335        // src: rename fac → fac2.
336        let rename_op = Operation::new(
337            OperationKind::RenameSymbol {
338                from: "fac".into(),
339                to: "fac2".into(),
340                body_stage_id: "s0".into(),
341            },
342            [root.clone()],
343        );
344        let rename_t = StageTransition::Rename {
345            from: "fac".into(), to: "fac2".into(),
346            body_stage_id: "s0".into(),
347        };
348        let src = apply(&log, Some(&root), rename_op, rename_t).unwrap().op_id;
349
350        // dst: modify fac body.
351        let dst = modify_body(&log, &root, "fac", "s0", "s-dst");
352
353        let out = merge(&log, Some(&src), Some(&dst)).unwrap();
354
355        // The `fac` sig should produce a conflict because both sides
356        // touched it (src via rename's `from`, dst via modify).
357        let fac_outcome = out.outcomes.iter().find(|o| match o {
358            MergeOutcome::Conflict { sig_id, .. }
359            | MergeOutcome::Src { sig_id, .. }
360            | MergeOutcome::Dst { sig_id, .. }
361            | MergeOutcome::Both { sig_id, .. } => sig_id == "fac",
362        });
363        assert!(matches!(fac_outcome, Some(MergeOutcome::Conflict { .. })),
364            "expected `fac` to be a conflict, got {fac_outcome:?}");
365    }
366}