Skip to main content

void_core/diff/
three_way.rs

1//! Three-way diff algorithm for merge operations.
2//!
3//! Computes file-level differences between a base commit and two branches (ours/theirs)
4//! to detect conflicts and auto-mergeable changes.
5
6use std::collections::{HashMap, HashSet};
7
8use serde::{Deserialize as SerdeDeserialize, Serialize};
9
10use super::collect::{collect_commit_files, load_commit_and_reader};
11use crate::cid::VoidCid;
12use crate::crypto::KeyVault;
13
14use crate::ContentHash;
15use crate::store::ObjectStoreExt;
16use crate::Result;
17
18/// Which side made a change in a three-way diff.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, SerdeDeserialize)]
20pub enum Side {
21    /// Our branch (the branch we're merging into).
22    Ours,
23    /// Their branch (the branch being merged).
24    Theirs,
25}
26
27/// Status of a file in a three-way diff.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, SerdeDeserialize)]
29pub enum ThreeWayStatus {
30    /// File unchanged in all three versions.
31    Unchanged,
32    /// Only our side modified the file (theirs unchanged from base).
33    OursOnly,
34    /// Only their side modified the file (ours unchanged from base).
35    TheirsOnly,
36    /// Both sides made identical changes (auto-mergeable).
37    BothSame,
38    /// Both sides modified differently (conflict).
39    Conflict,
40    /// Both sides added with same content (auto-mergeable).
41    AddedBothSame,
42    /// Both sides added with different content (conflict).
43    AddedBothDifferent,
44    /// One side deleted, other side modified (conflict).
45    DeleteModify {
46        /// Which side deleted the file.
47        deleted_by: Side,
48    },
49    /// Both sides deleted the file.
50    BothDeleted,
51}
52
53impl ThreeWayStatus {
54    /// Returns true if this status represents a conflict.
55    pub fn is_conflict(&self) -> bool {
56        matches!(
57            self,
58            ThreeWayStatus::Conflict
59                | ThreeWayStatus::AddedBothDifferent
60                | ThreeWayStatus::DeleteModify { .. }
61        )
62    }
63
64    /// Returns true if this status can be auto-merged.
65    pub fn is_auto_mergeable(&self) -> bool {
66        matches!(
67            self,
68            ThreeWayStatus::OursOnly
69                | ThreeWayStatus::TheirsOnly
70                | ThreeWayStatus::BothSame
71                | ThreeWayStatus::AddedBothSame
72                | ThreeWayStatus::BothDeleted
73        )
74    }
75}
76
77/// Result of a three-way diff operation.
78#[derive(Debug, Clone, Serialize, SerdeDeserialize)]
79pub struct ThreeWayDiff {
80    /// Status of each file by path.
81    pub files: HashMap<String, ThreeWayStatus>,
82    /// Paths that have conflicts (subset of files where status.is_conflict()).
83    pub conflicts: Vec<String>,
84    /// Paths that can be auto-merged (subset of files where status.is_auto_mergeable()).
85    pub auto_mergeable: Vec<String>,
86}
87
88impl ThreeWayDiff {
89    /// Creates an empty three-way diff (no changes).
90    pub fn empty() -> Self {
91        Self {
92            files: HashMap::new(),
93            conflicts: Vec::new(),
94            auto_mergeable: Vec::new(),
95        }
96    }
97
98    /// Returns true if there are no conflicts.
99    pub fn is_clean(&self) -> bool {
100        self.conflicts.is_empty()
101    }
102
103    /// Returns the number of files with changes.
104    pub fn len(&self) -> usize {
105        self.files.len()
106    }
107
108    /// Returns true if there are no changes.
109    pub fn is_empty(&self) -> bool {
110        self.files.is_empty()
111    }
112}
113
114/// Collects all file entries from a commit via its TreeManifest.
115fn collect_commit_file_map<S: ObjectStoreExt>(
116    store: &S,
117    vault: &KeyVault,
118    commit_cid: &VoidCid,
119) -> Result<HashMap<String, ContentHash>> {
120    let (commit, reader) = load_commit_and_reader(store, vault, commit_cid)?;
121    let files = collect_commit_files(store, &commit, &reader)?;
122    Ok(files.into_iter().map(|f| (f.path, f.content_hash)).collect())
123}
124
125/// Classifies the status of a file given its hash in base, ours, and theirs.
126///
127/// Uses the following rules:
128/// | Base | Ours | Theirs | Result |
129/// |------|------|--------|--------|
130/// | A    | A    | A      | Unchanged |
131/// | A    | A    | B      | TheirsOnly |
132/// | A    | B    | A      | OursOnly |
133/// | A    | B    | B      | BothSame |
134/// | A    | B    | C      | Conflict |
135/// | -    | A    | A      | AddedBothSame |
136/// | -    | A    | B      | AddedBothDifferent |
137/// | A    | -    | A      | OursOnly (deleted) |
138/// | A    | A    | -      | TheirsOnly (deleted) |
139/// | A    | -    | -      | BothDeleted |
140/// | A    | B    | -      | DeleteModify(Theirs) |
141/// | A    | -    | B      | DeleteModify(Ours) |
142fn classify_file(
143    base: Option<&ContentHash>,
144    ours: Option<&ContentHash>,
145    theirs: Option<&ContentHash>,
146) -> Option<ThreeWayStatus> {
147    match (base, ours, theirs) {
148        // All three present
149        (Some(b), Some(o), Some(t)) => {
150            if o == b && t == b {
151                Some(ThreeWayStatus::Unchanged)
152            } else if o == b && t != b {
153                Some(ThreeWayStatus::TheirsOnly)
154            } else if o != b && t == b {
155                Some(ThreeWayStatus::OursOnly)
156            } else if o == t {
157                Some(ThreeWayStatus::BothSame)
158            } else {
159                Some(ThreeWayStatus::Conflict)
160            }
161        }
162
163        // No base (file added in both)
164        (None, Some(o), Some(t)) => {
165            if o == t {
166                Some(ThreeWayStatus::AddedBothSame)
167            } else {
168                Some(ThreeWayStatus::AddedBothDifferent)
169            }
170        }
171
172        // No ours (we deleted or never had)
173        (Some(b), None, Some(t)) => {
174            if t == b {
175                Some(ThreeWayStatus::OursOnly)
176            } else {
177                Some(ThreeWayStatus::DeleteModify {
178                    deleted_by: Side::Ours,
179                })
180            }
181        }
182
183        // No theirs (they deleted or never had)
184        (Some(b), Some(o), None) => {
185            if o == b {
186                Some(ThreeWayStatus::TheirsOnly)
187            } else {
188                Some(ThreeWayStatus::DeleteModify {
189                    deleted_by: Side::Theirs,
190                })
191            }
192        }
193
194        // Both deleted
195        (Some(_), None, None) => Some(ThreeWayStatus::BothDeleted),
196
197        // Only in ours (added by us only)
198        (None, Some(_), None) => Some(ThreeWayStatus::OursOnly),
199
200        // Only in theirs (added by them only)
201        (None, None, Some(_)) => Some(ThreeWayStatus::TheirsOnly),
202
203        // None have it
204        (None, None, None) => None,
205    }
206}
207
208/// Computes a three-way diff between a base commit and two branch commits.
209pub fn diff_three_way<S: ObjectStoreExt>(
210    store: &S,
211    vault: &KeyVault,
212    base: Option<&VoidCid>,
213    ours: &VoidCid,
214    theirs: &VoidCid,
215) -> Result<ThreeWayDiff> {
216    // Load file lists from all three commits via manifest
217    let base_files: HashMap<String, ContentHash> = match base {
218        Some(cid) => collect_commit_file_map(store, vault, cid)?,
219        None => HashMap::new(),
220    };
221
222    let ours_files = collect_commit_file_map(store, vault, ours)?;
223    let theirs_files = collect_commit_file_map(store, vault, theirs)?;
224
225    // Collect all unique paths across all three commits
226    let mut all_paths: HashSet<&str> = HashSet::new();
227    all_paths.extend(base_files.keys().map(|s| s.as_str()));
228    all_paths.extend(ours_files.keys().map(|s| s.as_str()));
229    all_paths.extend(theirs_files.keys().map(|s| s.as_str()));
230
231    let mut files = HashMap::new();
232    let mut conflicts = Vec::new();
233    let mut auto_mergeable = Vec::new();
234
235    // Classify each file
236    for path in all_paths {
237        let base_hash = base_files.get(path);
238        let ours_hash = ours_files.get(path);
239        let theirs_hash = theirs_files.get(path);
240
241        if let Some(status) = classify_file(base_hash, ours_hash, theirs_hash) {
242            // Skip unchanged files to keep the result minimal
243            if matches!(status, ThreeWayStatus::Unchanged) {
244                continue;
245            }
246
247            let path_owned = path.to_string();
248
249            if status.is_conflict() {
250                conflicts.push(path_owned.clone());
251            } else if status.is_auto_mergeable() {
252                auto_mergeable.push(path_owned.clone());
253            }
254
255            files.insert(path_owned, status);
256        }
257    }
258
259    // Sort for deterministic output
260    conflicts.sort();
261    auto_mergeable.sort();
262
263    Ok(ThreeWayDiff {
264        files,
265        conflicts,
266        auto_mergeable,
267    })
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    // Helper to create a test hash from a simple value
275    fn hash(val: u8) -> ContentHash {
276        let mut h = [0u8; 32];
277        h[0] = val;
278        ContentHash(h)
279    }
280
281    #[test]
282    fn test_classify_unchanged() {
283        let a = hash(1);
284        let result = classify_file(Some(&a), Some(&a), Some(&a));
285        assert_eq!(result, Some(ThreeWayStatus::Unchanged));
286    }
287
288    #[test]
289    fn test_classify_theirs_only() {
290        let a = hash(1);
291        let b = hash(2);
292        let result = classify_file(Some(&a), Some(&a), Some(&b));
293        assert_eq!(result, Some(ThreeWayStatus::TheirsOnly));
294    }
295
296    #[test]
297    fn test_classify_ours_only() {
298        let a = hash(1);
299        let b = hash(2);
300        let result = classify_file(Some(&a), Some(&b), Some(&a));
301        assert_eq!(result, Some(ThreeWayStatus::OursOnly));
302    }
303
304    #[test]
305    fn test_classify_both_same() {
306        let a = hash(1);
307        let b = hash(2);
308        let result = classify_file(Some(&a), Some(&b), Some(&b));
309        assert_eq!(result, Some(ThreeWayStatus::BothSame));
310    }
311
312    #[test]
313    fn test_classify_conflict() {
314        let a = hash(1);
315        let b = hash(2);
316        let c = hash(3);
317        let result = classify_file(Some(&a), Some(&b), Some(&c));
318        assert_eq!(result, Some(ThreeWayStatus::Conflict));
319    }
320
321    #[test]
322    fn test_classify_added_both_same() {
323        let a = hash(1);
324        let result = classify_file(None, Some(&a), Some(&a));
325        assert_eq!(result, Some(ThreeWayStatus::AddedBothSame));
326    }
327
328    #[test]
329    fn test_classify_added_both_different() {
330        let a = hash(1);
331        let b = hash(2);
332        let result = classify_file(None, Some(&a), Some(&b));
333        assert_eq!(result, Some(ThreeWayStatus::AddedBothDifferent));
334    }
335
336    #[test]
337    fn test_classify_ours_deleted() {
338        let a = hash(1);
339        let result = classify_file(Some(&a), None, Some(&a));
340        assert_eq!(result, Some(ThreeWayStatus::OursOnly));
341    }
342
343    #[test]
344    fn test_classify_theirs_deleted() {
345        let a = hash(1);
346        let result = classify_file(Some(&a), Some(&a), None);
347        assert_eq!(result, Some(ThreeWayStatus::TheirsOnly));
348    }
349
350    #[test]
351    fn test_classify_both_deleted() {
352        let a = hash(1);
353        let result = classify_file(Some(&a), None, None);
354        assert_eq!(result, Some(ThreeWayStatus::BothDeleted));
355    }
356
357    #[test]
358    fn test_classify_delete_modify_ours_deleted() {
359        let a = hash(1);
360        let b = hash(2);
361        let result = classify_file(Some(&a), None, Some(&b));
362        assert_eq!(
363            result,
364            Some(ThreeWayStatus::DeleteModify {
365                deleted_by: Side::Ours
366            })
367        );
368    }
369
370    #[test]
371    fn test_classify_delete_modify_theirs_deleted() {
372        let a = hash(1);
373        let b = hash(2);
374        let result = classify_file(Some(&a), Some(&b), None);
375        assert_eq!(
376            result,
377            Some(ThreeWayStatus::DeleteModify {
378                deleted_by: Side::Theirs
379            })
380        );
381    }
382
383    #[test]
384    fn test_classify_ours_added_only() {
385        let a = hash(1);
386        let result = classify_file(None, Some(&a), None);
387        assert_eq!(result, Some(ThreeWayStatus::OursOnly));
388    }
389
390    #[test]
391    fn test_classify_theirs_added_only() {
392        let a = hash(1);
393        let result = classify_file(None, None, Some(&a));
394        assert_eq!(result, Some(ThreeWayStatus::TheirsOnly));
395    }
396
397    #[test]
398    fn test_classify_none() {
399        let result = classify_file(None, None, None);
400        assert_eq!(result, None);
401    }
402
403    #[test]
404    fn test_status_is_conflict() {
405        assert!(ThreeWayStatus::Conflict.is_conflict());
406        assert!(ThreeWayStatus::AddedBothDifferent.is_conflict());
407        assert!(ThreeWayStatus::DeleteModify {
408            deleted_by: Side::Ours
409        }
410        .is_conflict());
411        assert!(ThreeWayStatus::DeleteModify {
412            deleted_by: Side::Theirs
413        }
414        .is_conflict());
415
416        assert!(!ThreeWayStatus::Unchanged.is_conflict());
417        assert!(!ThreeWayStatus::OursOnly.is_conflict());
418        assert!(!ThreeWayStatus::TheirsOnly.is_conflict());
419        assert!(!ThreeWayStatus::BothSame.is_conflict());
420        assert!(!ThreeWayStatus::AddedBothSame.is_conflict());
421        assert!(!ThreeWayStatus::BothDeleted.is_conflict());
422    }
423
424    #[test]
425    fn test_status_is_auto_mergeable() {
426        assert!(ThreeWayStatus::OursOnly.is_auto_mergeable());
427        assert!(ThreeWayStatus::TheirsOnly.is_auto_mergeable());
428        assert!(ThreeWayStatus::BothSame.is_auto_mergeable());
429        assert!(ThreeWayStatus::AddedBothSame.is_auto_mergeable());
430        assert!(ThreeWayStatus::BothDeleted.is_auto_mergeable());
431
432        assert!(!ThreeWayStatus::Unchanged.is_auto_mergeable());
433        assert!(!ThreeWayStatus::Conflict.is_auto_mergeable());
434        assert!(!ThreeWayStatus::AddedBothDifferent.is_auto_mergeable());
435        assert!(!ThreeWayStatus::DeleteModify {
436            deleted_by: Side::Ours
437        }
438        .is_auto_mergeable());
439    }
440
441    #[test]
442    fn test_three_way_diff_empty() {
443        let diff = ThreeWayDiff::empty();
444        assert!(diff.is_empty());
445        assert!(diff.is_clean());
446        assert_eq!(diff.len(), 0);
447    }
448}