Skip to main content

dotling/sync/
merge.rs

1//! Line-level three-way merge.
2//!
3//! Given a common `base` and two diverged versions (`ours` and `theirs`),
4//! produces a merged result.  Non-conflicting changes from both sides are
5//! applied automatically; overlapping changes are wrapped in conflict markers:
6//!
7//! ```text
8//! <<<<<<< ours
9//! line only in ours
10//! =======
11//! line only in theirs
12//! >>>>>>> theirs
13//! ```
14//!
15//! This is intentionally simple — it is line-granular and does not attempt
16//! word-level or semantic merging.  It mirrors the behaviour of `git merge-file`
17//! with default (non-diff3) conflict style.
18
19use std::fmt::Write as _;
20
21// ── Public API ────────────────────────────────────────────────────
22
23/// Result of a three-way merge.
24#[derive(Debug)]
25pub struct MergeResult {
26    /// The merged text (may contain conflict markers if `has_conflicts` is true).
27    pub content: String,
28    /// `true` if any conflicting hunks were found and could not be auto-resolved.
29    pub has_conflicts: bool,
30    /// Number of conflict hunks inserted.
31    pub conflict_count: usize,
32}
33
34/// Perform a line-level three-way merge.
35///
36/// - `base`   — the common ancestor (last-known-good snapshot).
37/// - `ours`   — the repo version (what the dotling repo contains).
38/// - `theirs` — the actual / local version (what is on disk).
39/// - `ours_label`   — label shown in `<<<<<<<` marker (e.g. `"repo"`).
40/// - `theirs_label` — label shown in `>>>>>>>` marker (e.g. `"actual"`).
41pub fn three_way_merge(
42    base: &str,
43    ours: &str,
44    theirs: &str,
45    ours_label: &str,
46    theirs_label: &str,
47) -> MergeResult {
48    let base_lines: Vec<&str> = base.lines().collect();
49    let ours_lines: Vec<&str> = ours.lines().collect();
50    let theirs_lines: Vec<&str> = theirs.lines().collect();
51
52    let ours_ops = diff(&base_lines, &ours_lines);
53    let theirs_ops = diff(&base_lines, &theirs_lines);
54
55    merge_ops(
56        &base_lines,
57        &ours_ops,
58        &theirs_ops,
59        &ours_lines,
60        &theirs_lines,
61        ours_label,
62        theirs_label,
63    )
64}
65
66// ── Diff ──────────────────────────────────────────────────────────
67
68/// A single edit operation relative to the base sequence.
69#[derive(Debug, Clone, PartialEq, Eq)]
70enum Op {
71    /// Keep the base line at this index unchanged.
72    Keep(usize),
73    /// Delete the base line at this index.
74    Delete(usize),
75    /// Insert a new line (index into the other sequence) before the current base position.
76    Insert(usize),
77}
78
79/// Compute a diff between `a` (base) and `b` (modified) using a simple LCS
80/// approach (patience-inspired).  Returns a sequence of `Op`s.
81fn diff<'a>(a: &[&'a str], b: &[&'a str]) -> Vec<Op> {
82    let lcs = lcs_table(a, b);
83    let mut ops = Vec::new();
84    build_ops(a, b, a.len(), b.len(), &lcs, &mut ops);
85    ops
86}
87
88/// Build the LCS length table.
89fn lcs_table(a: &[&str], b: &[&str]) -> Vec<Vec<usize>> {
90    let m = a.len();
91    let n = b.len();
92    let mut dp = vec![vec![0usize; n + 1]; m + 1];
93    for i in 1..=m {
94        for j in 1..=n {
95            dp[i][j] = if a[i - 1] == b[j - 1] {
96                dp[i - 1][j - 1] + 1
97            } else {
98                dp[i - 1][j].max(dp[i][j - 1])
99            };
100        }
101    }
102    dp
103}
104
105/// Recursively walk the LCS table to produce edit operations.
106fn build_ops(a: &[&str], b: &[&str], i: usize, j: usize, dp: &[Vec<usize>], ops: &mut Vec<Op>) {
107    if i == 0 && j == 0 {
108        return;
109    }
110    if i == 0 {
111        build_ops(a, b, i, j - 1, dp, ops);
112        ops.push(Op::Insert(j - 1));
113    } else if j == 0 {
114        build_ops(a, b, i - 1, j, dp, ops);
115        ops.push(Op::Delete(i - 1));
116    } else if a[i - 1] == b[j - 1] {
117        build_ops(a, b, i - 1, j - 1, dp, ops);
118        ops.push(Op::Keep(i - 1));
119    } else if dp[i - 1][j] >= dp[i][j - 1] {
120        build_ops(a, b, i - 1, j, dp, ops);
121        ops.push(Op::Delete(i - 1));
122    } else {
123        build_ops(a, b, i, j - 1, dp, ops);
124        ops.push(Op::Insert(j - 1));
125    }
126}
127
128// ── Merge ─────────────────────────────────────────────────────────
129
130struct SideState<'a> {
131    inserted: Vec<Vec<&'a str>>,
132    deleted: Vec<bool>,
133}
134
135fn get_side_state<'a>(base_len: usize, ops: &[Op], other_lines: &[&'a str]) -> SideState<'a> {
136    let mut inserted = vec![Vec::new(); base_len + 1];
137    let mut deleted = vec![false; base_len];
138    let mut b_idx = 0;
139    for op in ops {
140        match op {
141            Op::Keep(b) => {
142                b_idx = *b;
143                if b_idx < base_len {
144                    b_idx += 1;
145                }
146            }
147            Op::Delete(b) => {
148                b_idx = *b;
149                if b_idx < base_len {
150                    deleted[b_idx] = true;
151                    b_idx += 1;
152                }
153            }
154            Op::Insert(o) => {
155                if b_idx <= base_len {
156                    inserted[b_idx].push(other_lines[*o]);
157                }
158            }
159        }
160    }
161    SideState { inserted, deleted }
162}
163
164/// Align the two op streams against the shared base and produce hunks.
165#[allow(clippy::too_many_lines)]
166#[allow(clippy::too_many_arguments)]
167#[allow(clippy::needless_range_loop)]
168fn merge_ops(
169    base_lines: &[&str],
170    ours_ops: &[Op],
171    theirs_ops: &[Op],
172    ours_lines: &[&str],
173    theirs_lines: &[&str],
174    ours_label: &str,
175    theirs_label: &str,
176) -> MergeResult {
177    let base_len = base_lines.len();
178    let ours_state = get_side_state(base_len, ours_ops, ours_lines);
179    let theirs_state = get_side_state(base_len, theirs_ops, theirs_lines);
180
181    // Identify which base lines are "cleanly kept" boundaries.
182    // A base line b (0..base_len) is a boundary if:
183    // 1. Neither side deleted it (!ours_state.deleted[b] && !theirs_state.deleted[b]).
184    // 2. Neither side had any insertions immediately before it (ours_state.inserted[b].is_empty()
185    //    && theirs_state.inserted[b].is_empty()).
186    let mut is_boundary = vec![false; base_len];
187    for b in 0..base_len {
188        if !ours_state.deleted[b]
189            && !theirs_state.deleted[b]
190            && ours_state.inserted[b].is_empty()
191            && theirs_state.inserted[b].is_empty()
192        {
193            is_boundary[b] = true;
194        }
195    }
196
197    let mut out = String::new();
198    let mut has_conflicts = false;
199    let mut conflict_count = 0;
200
201    let mut b = 0;
202    while b <= base_len {
203        if b < base_len && is_boundary[b] {
204            // Output boundary line cleanly.
205            out.push_str(base_lines[b]);
206            out.push('\n');
207            b += 1;
208        } else {
209            // Find the end of this change block.
210            let start = b;
211            let mut end = b;
212            while end < base_len && !is_boundary[end] {
213                end += 1;
214            }
215
216            let mut ours_prod = Vec::new();
217            let mut theirs_prod = Vec::new();
218            let mut ours_has_edits = false;
219            let mut theirs_has_edits = false;
220
221            for curr in start..=end {
222                // Collect insertions before base line curr.
223                for &line in &ours_state.inserted[curr] {
224                    ours_prod.push(line);
225                    ours_has_edits = true;
226                }
227                for &line in &theirs_state.inserted[curr] {
228                    theirs_prod.push(line);
229                    theirs_has_edits = true;
230                }
231
232                // If curr < end, handle the base line curr itself.
233                if curr < end {
234                    if ours_state.deleted[curr] {
235                        ours_has_edits = true;
236                    } else {
237                        ours_prod.push(base_lines[curr]);
238                    }
239
240                    if theirs_state.deleted[curr] {
241                        theirs_has_edits = true;
242                    } else {
243                        theirs_prod.push(base_lines[curr]);
244                    }
245                }
246            }
247
248            // Decide how to merge this change block.
249            if ours_has_edits && theirs_has_edits {
250                // Both changed this block.
251                if ours_prod == theirs_prod {
252                    // Both produced the same result -> auto-merge.
253                    for line in ours_prod {
254                        out.push_str(line);
255                        out.push('\n');
256                    }
257                } else {
258                    // Conflict!
259                    has_conflicts = true;
260                    conflict_count += 1;
261                    let _ = writeln!(out, "<<<<<<< {ours_label}");
262                    for line in ours_prod {
263                        out.push_str(line);
264                        out.push('\n');
265                    }
266                    out.push_str("=======\n");
267                    for line in theirs_prod {
268                        out.push_str(line);
269                        out.push('\n');
270                    }
271                    let _ = writeln!(out, ">>>>>>> {theirs_label}");
272                }
273            } else if ours_has_edits {
274                // Only ours changed this block.
275                for line in ours_prod {
276                    out.push_str(line);
277                    out.push('\n');
278                }
279            } else if theirs_has_edits {
280                // Only theirs changed this block.
281                for line in theirs_prod {
282                    out.push_str(line);
283                    out.push('\n');
284                }
285            } else {
286                // Neither side had edits -> output base lines.
287                for curr in start..end {
288                    out.push_str(base_lines[curr]);
289                    out.push('\n');
290                }
291            }
292
293            if end == base_len {
294                b = base_len + 1;
295            } else {
296                b = end;
297            }
298        }
299    }
300
301    let newline_needed = base_lines
302        .last()
303        .or(ours_lines.last())
304        .or(theirs_lines.last())
305        .is_some_and(|l| !l.is_empty());
306
307    // Trim a trailing newline if the original had none.
308    if !newline_needed && out.ends_with('\n') {
309        out.pop();
310    }
311
312    MergeResult {
313        content: out,
314        has_conflicts,
315        conflict_count,
316    }
317}
318
319// ── Tests ─────────────────────────────────────────────────────────
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn identical_files_no_conflict() {
327        let r = three_way_merge("a\nb\nc\n", "a\nb\nc\n", "a\nb\nc\n", "repo", "actual");
328        assert!(!r.has_conflicts);
329        assert_eq!(r.conflict_count, 0);
330    }
331
332    #[test]
333    fn only_ours_changed() {
334        let r = three_way_merge("a\nb\nc\n", "a\nB\nc\n", "a\nb\nc\n", "repo", "actual");
335        assert!(!r.has_conflicts);
336        assert!(r.content.contains('B'));
337        assert!(!r.content.contains('b'));
338    }
339
340    #[test]
341    fn only_theirs_changed() {
342        let r = three_way_merge("a\nb\nc\n", "a\nb\nc\n", "a\nB\nc\n", "repo", "actual");
343        assert!(!r.has_conflicts);
344        assert!(r.content.contains('B'));
345    }
346
347    #[test]
348    fn non_overlapping_changes_auto_merged() {
349        let base = "a\nb\nc\nd\n";
350        let ours = "A\nb\nc\nd\n"; // changed first line
351        let theirs = "a\nb\nc\nD\n"; // changed last line
352        let r = three_way_merge(base, ours, theirs, "repo", "actual");
353        assert!(!r.has_conflicts, "no overlap — should auto-merge");
354        assert!(r.content.contains('A'));
355        assert!(r.content.contains('D'));
356    }
357
358    #[test]
359    fn overlapping_changes_produce_conflict() {
360        let base = "a\nb\nc\n";
361        let ours = "a\nX\nc\n";
362        let theirs = "a\nY\nc\n";
363        let r = three_way_merge(base, ours, theirs, "repo", "actual");
364        assert!(r.has_conflicts);
365        assert_eq!(r.conflict_count, 1);
366        assert!(r.content.contains("<<<<<<< repo"));
367        assert!(r.content.contains(">>>>>>> actual"));
368        assert!(r.content.contains('X'));
369        assert!(r.content.contains('Y'));
370    }
371
372    #[test]
373    fn both_add_same_line_no_conflict() {
374        // Both sides appended the same line — should not conflict.
375        let base = "a\n";
376        let ours = "a\nnew\n";
377        let theirs = "a\nnew\n";
378        let r = three_way_merge(base, ours, theirs, "repo", "actual");
379        // May or may not auto-resolve, but if it conflicts the content is still correct.
380        // At minimum, "new" must appear in the output.
381        assert!(r.content.contains("new"));
382    }
383}