Skip to main content

lit/core/
merge.rs

1/// 3-Way Merge Engine
2///
3/// Implements recursive 3-way merge with structured conflict output.
4/// Supports strategies: recursive (default), ours, theirs.
5use crate::core::diff::{myers_diff, DiffOp};
6use crate::core::{Object, ObjectHash, Tree, TreeEntry};
7use crate::storage::ObjectStore;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Merge strategy
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum MergeStrategy {
14    Recursive,
15    Ours,
16    Theirs,
17}
18
19impl std::str::FromStr for MergeStrategy {
20    type Err = String;
21
22    fn from_str(s: &str) -> Result<Self, String> {
23        match s {
24            "recursive" => Ok(MergeStrategy::Recursive),
25            "ours" => Ok(MergeStrategy::Ours),
26            "theirs" => Ok(MergeStrategy::Theirs),
27            _ => Err(format!(
28                "Unknown merge strategy: '{}'. Valid: recursive, ours, theirs",
29                s
30            )),
31        }
32    }
33}
34
35/// Result of a merge operation
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct MergeResult {
38    /// The merged tree (if merge succeeded or has conflicts with partial results)
39    pub tree: Option<ObjectHash>,
40    /// Whether the merge was a fast-forward
41    pub fast_forward: bool,
42    /// Whether conflicts were detected
43    pub has_conflicts: bool,
44    /// Per-file merge results
45    pub file_results: Vec<FileMergeResult>,
46    /// Strategy used
47    pub strategy: String,
48}
49
50/// Per-file merge result
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct FileMergeResult {
53    pub path: String,
54    pub status: FileMergeStatus,
55    /// Conflict regions (only present if status == Conflict)
56    #[serde(skip_serializing_if = "Vec::is_empty")]
57    pub conflicts: Vec<ConflictRegion>,
58}
59
60/// File-level merge status
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum FileMergeStatus {
64    Clean,
65    Conflict,
66    Added,
67    Deleted,
68    /// Both sides modified but auto-resolved
69    AutoResolved,
70}
71
72/// A single conflict region in a file
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ConflictRegion {
75    pub start_line: usize,
76    pub ours: Vec<String>,
77    pub theirs: Vec<String>,
78    pub base: Vec<String>,
79}
80
81/// Find the common ancestor (merge base) between two commits
82pub fn find_merge_base(
83    store: &ObjectStore,
84    commit_a: &ObjectHash,
85    commit_b: &ObjectHash,
86) -> Result<Option<ObjectHash>, String> {
87    // BFS from both commits, first intersection is the merge base
88    let mut visited_a: HashMap<String, usize> = HashMap::new();
89    let mut visited_b: HashMap<String, usize> = HashMap::new();
90    let mut queue_a = vec![commit_a.clone()];
91    let mut queue_b = vec![commit_b.clone()];
92    let mut depth = 0usize;
93
94    // Alternating BFS from both sides
95    while !queue_a.is_empty() || !queue_b.is_empty() {
96        // Process queue A
97        let mut next_a = Vec::new();
98        for hash in &queue_a {
99            let key = hash.to_string();
100            if visited_b.contains_key(&key) {
101                return Ok(Some(hash.clone()));
102            }
103            if visited_a.contains_key(&key) {
104                continue;
105            }
106            visited_a.insert(key, depth);
107
108            if let Ok(Object::Commit(commit)) = store.read(hash) {
109                for parent in &commit.parents {
110                    if !visited_a.contains_key(&parent.to_string()) {
111                        next_a.push(parent.clone());
112                    }
113                }
114            }
115        }
116        queue_a = next_a;
117
118        // Process queue B
119        let mut next_b = Vec::new();
120        for hash in &queue_b {
121            let key = hash.to_string();
122            if visited_a.contains_key(&key) {
123                return Ok(Some(hash.clone()));
124            }
125            if visited_b.contains_key(&key) {
126                continue;
127            }
128            visited_b.insert(key, depth);
129
130            if let Ok(Object::Commit(commit)) = store.read(hash) {
131                for parent in &commit.parents {
132                    if !visited_b.contains_key(&parent.to_string()) {
133                        next_b.push(parent.clone());
134                    }
135                }
136            }
137        }
138        queue_b = next_b;
139        depth += 1;
140
141        // Safety limit
142        if depth > 10000 {
143            return Err("Merge base search exceeded depth limit".to_string());
144        }
145    }
146
147    Ok(None) // No common ancestor
148}
149
150/// Check if commit_a is an ancestor of commit_b (for fast-forward detection)
151pub fn is_ancestor(
152    store: &ObjectStore,
153    ancestor: &ObjectHash,
154    descendant: &ObjectHash,
155) -> Result<bool, String> {
156    if ancestor.to_string() == descendant.to_string() {
157        return Ok(true);
158    }
159
160    let mut queue = vec![descendant.clone()];
161    let mut visited = std::collections::HashSet::new();
162
163    while let Some(hash) = queue.pop() {
164        let key = hash.to_string();
165        if key == ancestor.to_string() {
166            return Ok(true);
167        }
168        if !visited.insert(key) {
169            continue;
170        }
171        if let Ok(Object::Commit(commit)) = store.read(&hash) {
172            for parent in &commit.parents {
173                queue.push(parent.clone());
174            }
175        }
176    }
177
178    Ok(false)
179}
180
181/// Perform a 3-way merge between two trees with a common base
182pub fn merge_trees(
183    store: &ObjectStore,
184    base_tree: Option<&Tree>,
185    ours_tree: &Tree,
186    theirs_tree: &Tree,
187    strategy: MergeStrategy,
188) -> Result<MergeResult, String> {
189    // Collect files from all three trees
190    let base_files = match base_tree {
191        Some(t) => crate::core::diff::collect_tree_files(t, store, "")?,
192        None => vec![],
193    };
194    let ours_files = crate::core::diff::collect_tree_files(ours_tree, store, "")?;
195    let theirs_files = crate::core::diff::collect_tree_files(theirs_tree, store, "")?;
196
197    let base_map: HashMap<String, ObjectHash> = base_files.into_iter().collect();
198    let ours_map: HashMap<String, ObjectHash> = ours_files.into_iter().collect();
199    let theirs_map: HashMap<String, ObjectHash> = theirs_files.into_iter().collect();
200
201    // Collect all file paths
202    let mut all_paths: Vec<String> = ours_map
203        .keys()
204        .chain(theirs_map.keys())
205        .chain(base_map.keys())
206        .cloned()
207        .collect();
208    all_paths.sort();
209    all_paths.dedup();
210
211    let mut file_results = Vec::new();
212    let mut merged_entries: Vec<(String, ObjectHash)> = Vec::new();
213    let mut has_conflicts = false;
214
215    for path in &all_paths {
216        let base_hash = base_map.get(path);
217        let ours_hash = ours_map.get(path);
218        let theirs_hash = theirs_map.get(path);
219
220        let result = merge_file_entry(store, path, base_hash, ours_hash, theirs_hash, strategy)?;
221
222        if result.status == FileMergeStatus::Conflict {
223            has_conflicts = true;
224        }
225
226        // Determine which hash to use in the merged tree
227        match result.status {
228            FileMergeStatus::Clean | FileMergeStatus::AutoResolved => {
229                // Use theirs if only theirs changed, ours otherwise
230                if let Some(h) = ours_hash {
231                    if base_hash.map(|b| b == h).unwrap_or(false) {
232                        // Ours unchanged from base, use theirs
233                        if let Some(th) = theirs_hash {
234                            merged_entries.push((path.clone(), th.clone()));
235                        }
236                    } else {
237                        merged_entries.push((path.clone(), h.clone()));
238                    }
239                } else if let Some(th) = theirs_hash {
240                    merged_entries.push((path.clone(), th.clone()));
241                }
242            }
243            FileMergeStatus::Added => {
244                if let Some(h) = ours_hash.or(theirs_hash) {
245                    merged_entries.push((path.clone(), h.clone()));
246                }
247            }
248            FileMergeStatus::Deleted => {
249                // Don't include in merged tree
250            }
251            FileMergeStatus::Conflict => {
252                // For conflicts with strategy override, pick accordingly
253                match strategy {
254                    MergeStrategy::Ours => {
255                        if let Some(h) = ours_hash {
256                            merged_entries.push((path.clone(), h.clone()));
257                        }
258                    }
259                    MergeStrategy::Theirs => {
260                        if let Some(h) = theirs_hash {
261                            merged_entries.push((path.clone(), h.clone()));
262                        }
263                    }
264                    MergeStrategy::Recursive => {
265                        // Keep ours version in tree, conflicts recorded separately
266                        if let Some(h) = ours_hash {
267                            merged_entries.push((path.clone(), h.clone()));
268                        }
269                    }
270                }
271            }
272        }
273
274        file_results.push(result);
275    }
276
277    // Build merged tree
278    let tree_hash = if !has_conflicts || strategy != MergeStrategy::Recursive {
279        Some(build_flat_tree(store, &merged_entries)?)
280    } else {
281        None // Don't create tree when there are unresolved conflicts
282    };
283
284    Ok(MergeResult {
285        tree: tree_hash,
286        fast_forward: false,
287        has_conflicts,
288        file_results,
289        strategy: format!("{:?}", strategy).to_lowercase(),
290    })
291}
292
293/// Merge a single file entry using 3-way merge
294fn merge_file_entry(
295    store: &ObjectStore,
296    path: &str,
297    base_hash: Option<&ObjectHash>,
298    ours_hash: Option<&ObjectHash>,
299    theirs_hash: Option<&ObjectHash>,
300    strategy: MergeStrategy,
301) -> Result<FileMergeResult, String> {
302    match (base_hash, ours_hash, theirs_hash) {
303        // File exists in base but not in both branches — both deleted
304        (Some(_), None, None) => Ok(FileMergeResult {
305            path: path.to_string(),
306            status: FileMergeStatus::Deleted,
307            conflicts: vec![],
308        }),
309
310        // File only in ours (added by us, not in base or theirs)
311        (None, Some(_), None) => Ok(FileMergeResult {
312            path: path.to_string(),
313            status: FileMergeStatus::Added,
314            conflicts: vec![],
315        }),
316
317        // File only in theirs (added by them)
318        (None, None, Some(_)) => Ok(FileMergeResult {
319            path: path.to_string(),
320            status: FileMergeStatus::Added,
321            conflicts: vec![],
322        }),
323
324        // File added by both — check if same content
325        (None, Some(o), Some(t)) => {
326            if o == t {
327                Ok(FileMergeResult {
328                    path: path.to_string(),
329                    status: FileMergeStatus::Clean,
330                    conflicts: vec![],
331                })
332            } else {
333                handle_content_conflict(store, path, None, Some(o), Some(t), strategy)
334            }
335        }
336
337        // File in base and ours, deleted by theirs
338        (Some(b), Some(o), None) => {
339            if o == b {
340                // We didn't change it, they deleted it — accept deletion
341                Ok(FileMergeResult {
342                    path: path.to_string(),
343                    status: FileMergeStatus::Deleted,
344                    conflicts: vec![],
345                })
346            } else {
347                // We modified, they deleted — conflict
348                Ok(FileMergeResult {
349                    path: path.to_string(),
350                    status: FileMergeStatus::Conflict,
351                    conflicts: vec![ConflictRegion {
352                        start_line: 1,
353                        ours: vec!["(file modified)".to_string()],
354                        theirs: vec!["(file deleted)".to_string()],
355                        base: vec!["(file existed)".to_string()],
356                    }],
357                })
358            }
359        }
360
361        // File in base and theirs, deleted by ours
362        (Some(b), None, Some(t)) => {
363            if t == b {
364                // They didn't change it, we deleted it — accept deletion
365                Ok(FileMergeResult {
366                    path: path.to_string(),
367                    status: FileMergeStatus::Deleted,
368                    conflicts: vec![],
369                })
370            } else {
371                // They modified, we deleted — conflict
372                Ok(FileMergeResult {
373                    path: path.to_string(),
374                    status: FileMergeStatus::Conflict,
375                    conflicts: vec![ConflictRegion {
376                        start_line: 1,
377                        ours: vec!["(file deleted)".to_string()],
378                        theirs: vec!["(file modified)".to_string()],
379                        base: vec!["(file existed)".to_string()],
380                    }],
381                })
382            }
383        }
384
385        // File in all three — standard 3-way merge
386        (Some(b), Some(o), Some(t)) => {
387            if o == t {
388                // Both made same change (or neither changed)
389                Ok(FileMergeResult {
390                    path: path.to_string(),
391                    status: FileMergeStatus::Clean,
392                    conflicts: vec![],
393                })
394            } else if o == b {
395                // Only theirs changed — take theirs
396                Ok(FileMergeResult {
397                    path: path.to_string(),
398                    status: FileMergeStatus::Clean,
399                    conflicts: vec![],
400                })
401            } else if t == b {
402                // Only ours changed — take ours
403                Ok(FileMergeResult {
404                    path: path.to_string(),
405                    status: FileMergeStatus::Clean,
406                    conflicts: vec![],
407                })
408            } else {
409                // Both changed differently — need content-level merge
410                handle_content_conflict(store, path, Some(b), Some(o), Some(t), strategy)
411            }
412        }
413
414        // No file anywhere (shouldn't happen)
415        (None, None, None) => Ok(FileMergeResult {
416            path: path.to_string(),
417            status: FileMergeStatus::Clean,
418            conflicts: vec![],
419        }),
420    }
421}
422
423/// Handle content-level conflict between two versions
424fn handle_content_conflict(
425    store: &ObjectStore,
426    path: &str,
427    base_hash: Option<&ObjectHash>,
428    ours_hash: Option<&ObjectHash>,
429    theirs_hash: Option<&ObjectHash>,
430    strategy: MergeStrategy,
431) -> Result<FileMergeResult, String> {
432    // For ours/theirs strategies, no conflict
433    match strategy {
434        MergeStrategy::Ours => {
435            return Ok(FileMergeResult {
436                path: path.to_string(),
437                status: FileMergeStatus::AutoResolved,
438                conflicts: vec![],
439            });
440        }
441        MergeStrategy::Theirs => {
442            return Ok(FileMergeResult {
443                path: path.to_string(),
444                status: FileMergeStatus::AutoResolved,
445                conflicts: vec![],
446            });
447        }
448        MergeStrategy::Recursive => {}
449    }
450
451    // Read content from all versions
452    let base_content = match base_hash {
453        Some(h) => read_blob_text(store, h)?,
454        None => String::new(),
455    };
456    let ours_content = match ours_hash {
457        Some(h) => read_blob_text(store, h)?,
458        None => String::new(),
459    };
460    let theirs_content = match theirs_hash {
461        Some(h) => read_blob_text(store, h)?,
462        None => String::new(),
463    };
464
465    // Try line-level 3-way merge
466    let base_lines: Vec<&str> = base_content.lines().collect();
467    let ours_lines: Vec<&str> = ours_content.lines().collect();
468    let theirs_lines: Vec<&str> = theirs_content.lines().collect();
469
470    let merge_result = three_way_merge(&base_lines, &ours_lines, &theirs_lines);
471
472    if merge_result.conflicts.is_empty() {
473        Ok(FileMergeResult {
474            path: path.to_string(),
475            status: FileMergeStatus::AutoResolved,
476            conflicts: vec![],
477        })
478    } else {
479        Ok(FileMergeResult {
480            path: path.to_string(),
481            status: FileMergeStatus::Conflict,
482            conflicts: merge_result.conflicts,
483        })
484    }
485}
486
487/// Result of a 3-way line merge
488struct ThreeWayResult {
489    _merged_lines: Vec<String>,
490    conflicts: Vec<ConflictRegion>,
491}
492
493/// Perform line-level 3-way merge
494fn three_way_merge(base: &[&str], ours: &[&str], theirs: &[&str]) -> ThreeWayResult {
495    // Diff base→ours and base→theirs using raw diff ops (no context padding)
496    let ours_ops = myers_diff(base, ours);
497    let theirs_ops = myers_diff(base, theirs);
498
499    // Track which base lines each side actually modifies (removes or replaces)
500    let mut ours_changed_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
501    let mut theirs_changed_lines: std::collections::HashSet<usize> =
502        std::collections::HashSet::new();
503
504    {
505        let mut base_idx = 0usize;
506        for op in &ours_ops {
507            match op {
508                DiffOp::Equal(_) => {
509                    base_idx += 1;
510                }
511                DiffOp::Delete(_) => {
512                    ours_changed_lines.insert(base_idx);
513                    base_idx += 1;
514                }
515                DiffOp::Insert(_) => {
516                    ours_changed_lines.insert(base_idx);
517                }
518            }
519        }
520    }
521
522    {
523        let mut base_idx = 0usize;
524        for op in &theirs_ops {
525            match op {
526                DiffOp::Equal(_) => {
527                    base_idx += 1;
528                }
529                DiffOp::Delete(_) => {
530                    theirs_changed_lines.insert(base_idx);
531                    base_idx += 1;
532                }
533                DiffOp::Insert(_) => {
534                    theirs_changed_lines.insert(base_idx);
535                }
536            }
537        }
538    }
539
540    // Detect overlapping changes (conflicts)
541    let mut conflicts = Vec::new();
542    let overlapping: std::collections::HashSet<usize> = ours_changed_lines
543        .intersection(&theirs_changed_lines)
544        .cloned()
545        .collect();
546
547    if !overlapping.is_empty() {
548        let mut sorted_overlaps: Vec<usize> = overlapping.into_iter().collect();
549        sorted_overlaps.sort();
550
551        let mut regions: Vec<(usize, usize)> = Vec::new();
552        let mut start = sorted_overlaps[0];
553        let mut end = sorted_overlaps[0];
554
555        for &line in &sorted_overlaps[1..] {
556            if line <= end + 1 {
557                end = line;
558            } else {
559                regions.push((start, end));
560                start = line;
561                end = line;
562            }
563        }
564        regions.push((start, end));
565
566        for (start, end) in regions {
567            let base_region: Vec<String> = (start..=end)
568                .filter_map(|i| base.get(i).map(|s| s.to_string()))
569                .collect();
570            let ours_region: Vec<String> = (start..=end)
571                .filter_map(|i| ours.get(i).map(|s| s.to_string()))
572                .collect();
573            let theirs_region: Vec<String> = (start..=end)
574                .filter_map(|i| theirs.get(i).map(|s| s.to_string()))
575                .collect();
576
577            conflicts.push(ConflictRegion {
578                start_line: start + 1,
579                ours: ours_region,
580                theirs: theirs_region,
581                base: base_region,
582            });
583        }
584    }
585
586    // Build merged output
587    let mut merged_lines = Vec::new();
588    let max_len = base.len().max(ours.len()).max(theirs.len());
589
590    for i in 0..max_len {
591        if ours_changed_lines.contains(&i) && !theirs_changed_lines.contains(&i) {
592            if let Some(line) = ours.get(i) {
593                merged_lines.push(line.to_string());
594            }
595        } else if theirs_changed_lines.contains(&i) && !ours_changed_lines.contains(&i) {
596            if let Some(line) = theirs.get(i) {
597                merged_lines.push(line.to_string());
598            }
599        } else if !ours_changed_lines.contains(&i) && !theirs_changed_lines.contains(&i) {
600            if let Some(line) = base.get(i) {
601                merged_lines.push(line.to_string());
602            }
603        }
604    }
605
606    ThreeWayResult {
607        _merged_lines: merged_lines,
608        conflicts,
609    }
610}
611
612/// Read a blob as UTF-8 text
613fn read_blob_text(store: &ObjectStore, hash: &ObjectHash) -> Result<String, String> {
614    match store.read(hash)? {
615        Object::Blob(b) => String::from_utf8(b.content)
616            .map_err(|_| format!("File {} is binary, cannot merge", hash)),
617        _ => Err(format!("Expected blob object for hash {}", hash)),
618    }
619}
620
621/// Build a flat tree from (path, hash) pairs
622fn build_flat_tree(
623    store: &ObjectStore,
624    entries: &[(String, ObjectHash)],
625) -> Result<ObjectHash, String> {
626    // Group by top-level directory
627    let mut root_entries: Vec<TreeEntry> = Vec::new();
628    let mut subdirs: HashMap<String, Vec<(String, ObjectHash)>> = HashMap::new();
629
630    for (path, hash) in entries {
631        if let Some(sep) = path.find('/') {
632            let dir = &path[..sep];
633            let rest = &path[sep + 1..];
634            subdirs
635                .entry(dir.to_string())
636                .or_default()
637                .push((rest.to_string(), hash.clone()));
638        } else {
639            root_entries.push(TreeEntry {
640                mode: "100644".to_string(),
641                name: path.clone(),
642                hash: hash.clone(),
643                object_type: "blob".to_string(),
644            });
645        }
646    }
647
648    // Recursively build subtrees
649    for (dir, sub_entries) in &subdirs {
650        let subtree_hash = build_flat_tree(store, sub_entries)?;
651        root_entries.push(TreeEntry {
652            mode: "040000".to_string(),
653            name: dir.clone(),
654            hash: subtree_hash,
655            object_type: "tree".to_string(),
656        });
657    }
658
659    root_entries.sort_by(|a, b| a.name.cmp(&b.name));
660
661    let tree = Tree {
662        entries: root_entries,
663    };
664    let tree_obj = Object::Tree(tree);
665    store.write(&tree_obj)
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use std::str::FromStr;
672
673    #[test]
674    fn test_merge_strategy_from_str() {
675        assert_eq!(
676            MergeStrategy::from_str("recursive").unwrap(),
677            MergeStrategy::Recursive
678        );
679        assert_eq!(
680            MergeStrategy::from_str("ours").unwrap(),
681            MergeStrategy::Ours
682        );
683        assert_eq!(
684            MergeStrategy::from_str("theirs").unwrap(),
685            MergeStrategy::Theirs
686        );
687        assert!(MergeStrategy::from_str("invalid").is_err());
688    }
689
690    #[test]
691    fn test_three_way_merge_no_conflict() {
692        let base = vec!["line1", "line2", "line3"];
693        let ours = vec!["line1", "MODIFIED", "line3"];
694        let theirs = vec!["line1", "line2", "line3"];
695        let result = three_way_merge(&base, &ours, &theirs);
696        assert!(result.conflicts.is_empty());
697    }
698
699    #[test]
700    fn test_three_way_merge_both_sides_different_regions() {
701        let base = vec!["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
702        let ours = vec!["A", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
703        let theirs = vec!["a", "b", "c", "d", "e", "f", "g", "h", "i", "J"];
704        let result = three_way_merge(&base, &ours, &theirs);
705        assert!(result.conflicts.is_empty());
706        assert_eq!(
707            result._merged_lines,
708            vec!["A", "b", "c", "d", "e", "f", "g", "h", "i", "J"]
709        );
710    }
711
712    #[test]
713    fn test_three_way_merge_conflict() {
714        let base = vec!["line1", "line2", "line3"];
715        let ours = vec!["line1", "OURS", "line3"];
716        let theirs = vec!["line1", "THEIRS", "line3"];
717        let result = three_way_merge(&base, &ours, &theirs);
718        assert!(!result.conflicts.is_empty());
719    }
720}