crates/kernel/ents-sync/src/merge.rs
merge.rshistorycomment on this file
| 1 | //! The schema-aware three-way merge over typed trees — the one hard part |
| 2 | //! of sync (`sync.divergence-merge`). |
| 3 | //! |
| 4 | //! A meta-ref's tip is a git tree that `facet-git-tree` mapped directly |
| 5 | //! from a `#[derive(Facet)]` struct (`meta-ref.typed-tree`): a struct's |
| 6 | //! fields are tree entries, a nested struct or collection is a sub-tree, |
| 7 | //! and a scalar or string is a blob. Because the tree *is* the schema, a |
| 8 | //! structural three-way merge over the tree is a field-by-field merge over |
| 9 | //! the entity — never a textual merge over serialized bytes, which |
| 10 | //! `sync.divergence-merge` forbids. |
| 11 | //! |
| 12 | //! The merge is content-addressed, so it needs no diff heuristics: two |
| 13 | //! sides agree exactly when their object ids are equal. For each entry |
| 14 | //! (each field, each collection element) the classic three-way rule |
| 15 | //! applies against the merge-base tree `base`: |
| 16 | //! |
| 17 | //! - both sides equal → keep it (includes both-deleted and both-made-the- |
| 18 | //! same-change); |
| 19 | //! - one side equals `base` → the *other* side changed it, so take the |
| 20 | //! other (a deletion included); |
| 21 | //! - both sides changed it differently → recurse if both are sub-trees |
| 22 | //! (a nested struct or collection merges field-by-field), otherwise it |
| 23 | //! is a genuine conflict at that path. |
| 24 | //! |
| 25 | //! The result is either a clean merged tree (a new [`ObjectId`], ready to |
| 26 | //! become a merge tip whose signature makes it satisfy the tip invariant) |
| 27 | //! or the set of conflicting paths for a human to resolve. |
| 28 | |
| 29 | use std::collections::{BTreeSet, HashMap}; |
| 30 | |
| 31 | use gix::bstr::{BString, ByteVec as _}; |
| 32 | use gix_hash::ObjectId; |
| 33 | use gix_object::tree::{Entry as TreeEntry, EntryKind, EntryMode}; |
| 34 | use gix_object::{Find, TreeRef, Write}; |
| 35 | |
| 36 | use crate::error::{Error, Result}; |
| 37 | |
| 38 | /// The outcome of a schema-aware three-way merge ([`three_way`]). |
| 39 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 40 | pub enum Merge { |
| 41 | /// The two sides merged cleanly into this tree. A merge tip recording |
| 42 | /// it, signed by an authorized member, satisfies the tip invariant |
| 43 | /// (`sync.divergence-merge`). |
| 44 | Clean(ObjectId), |
| 45 | /// The two sides changed the same leaf differently. Each entry is a |
| 46 | /// slash-joined path into the typed tree — a field name, or a field |
| 47 | /// name and a collection index, exactly as `facet-git-tree` names |
| 48 | /// them — so a caller can report *which* piece of the entity clashed. |
| 49 | Conflict(Vec<BString>), |
| 50 | } |
| 51 | |
| 52 | impl Merge { |
| 53 | /// The clean merged tree, or `None` if the merge conflicted. |
| 54 | #[must_use] |
| 55 | pub fn tree(&self) -> Option<ObjectId> { |
| 56 | match self { |
| 57 | Merge::Clean(oid) => Some(*oid), |
| 58 | Merge::Conflict(_) => None, |
| 59 | } |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | /// One entry of a tree, reduced to what the merge compares: its object id |
| 64 | /// and whether it is a sub-tree (so recursion is possible) or a leaf. |
| 65 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 66 | struct Slot { |
| 67 | oid: ObjectId, |
| 68 | mode: EntryMode, |
| 69 | } |
| 70 | |
| 71 | impl Slot { |
| 72 | fn is_tree(self) -> bool { |
| 73 | self.mode.is_tree() |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | /// Schema-aware three-way merge of two typed trees against their merge |
| 78 | /// base (`sync.divergence-merge`). |
| 79 | /// |
| 80 | /// `base` is the tree of the merge-base commit — `None` when the two heads |
| 81 | /// share no common ancestor, which the merge treats as an empty base (every |
| 82 | /// entry looks added on both sides, so any difference is a conflict rather |
| 83 | /// than a silent pick). `ours` and `theirs` are the two divergent trees; |
| 84 | /// the merge is commutative, so their roles are symmetric. |
| 85 | /// |
| 86 | /// Merged sub-trees and blobs are written into `objects`; the returned |
| 87 | /// [`Merge::Clean`] id is the root of the new tree. |
| 88 | /// |
| 89 | /// # Errors |
| 90 | /// |
| 91 | /// [`Error::Decode`] if a purported tree does not decode, [`Error::Object`] |
| 92 | /// or [`Error::Missing`] if one cannot be read, [`Error::Write`] if the |
| 93 | /// merged tree cannot be stored. |
| 94 | /// |
| 95 | /// # Examples |
| 96 | /// |
| 97 | /// A one-sided change is adopted wholesale — the field-level analogue of a |
| 98 | /// fast-forward: |
| 99 | /// |
| 100 | /// ``` |
| 101 | /// use ents_sync::merge::{Merge, three_way}; |
| 102 | /// use ents_testutil::ObjectStore; |
| 103 | /// |
| 104 | /// // A stand-in for `ents-forge`'s `Issue` (this crate cannot depend on |
| 105 | /// // `ents-forge`): any Facet-derived entity exercises the merge. |
| 106 | /// # #[derive(facet::Facet, Clone)] |
| 107 | /// # struct Issue { title: String, body: String, state: String } |
| 108 | /// # |
| 109 | /// let objects = ObjectStore::default(); |
| 110 | /// let issue = Issue { |
| 111 | /// title: "t".into(), body: "b".into(), state: "open".into(), |
| 112 | /// }; |
| 113 | /// let base = facet_git_tree::serialize_into(&issue, &objects).expect("ser"); |
| 114 | /// let mut closed = issue.clone(); |
| 115 | /// closed.state = "closed".into(); |
| 116 | /// let theirs = facet_git_tree::serialize_into(&closed, &objects).expect("ser"); |
| 117 | /// |
| 118 | /// // ours == base (we changed nothing); theirs advanced. |
| 119 | /// let merged = three_way(&objects, Some(base), base, theirs).expect("merges"); |
| 120 | /// assert_eq!(merged, Merge::Clean(theirs)); |
| 121 | /// ``` |
| 122 | // @relation(sync.divergence-merge, scope=function) |
| 123 | pub fn three_way( |
| 124 | objects: &(impl Find + Write), |
| 125 | base: Option<ObjectId>, |
| 126 | ours: ObjectId, |
| 127 | theirs: ObjectId, |
| 128 | ) -> Result<Merge> { |
| 129 | // Content addressing makes the fast path exact: equal ids are equal |
| 130 | // subtrees, so identical sides need no walk at all. |
| 131 | if ours == theirs { |
| 132 | return Ok(Merge::Clean(ours)); |
| 133 | } |
| 134 | |
| 135 | let base_entries = match base { |
| 136 | Some(oid) => read_tree(objects, oid)?, |
| 137 | None => HashMap::new(), |
| 138 | }; |
| 139 | let ours_entries = read_tree(objects, ours)?; |
| 140 | let theirs_entries = read_tree(objects, theirs)?; |
| 141 | |
| 142 | let mut names: BTreeSet<&BString> = BTreeSet::new(); |
| 143 | names.extend(base_entries.keys()); |
| 144 | names.extend(ours_entries.keys()); |
| 145 | names.extend(theirs_entries.keys()); |
| 146 | |
| 147 | let mut merged: Vec<TreeEntry> = Vec::new(); |
| 148 | let mut conflicts: Vec<BString> = Vec::new(); |
| 149 | |
| 150 | for name in names { |
| 151 | let o = ours_entries.get(name).copied(); |
| 152 | let t = theirs_entries.get(name).copied(); |
| 153 | let b = base_entries.get(name).copied(); |
| 154 | |
| 155 | if o == t { |
| 156 | // Both sides agree, including both-absent and both-identical- |
| 157 | // change. Keep it when present. |
| 158 | push_slot(&mut merged, name, o); |
| 159 | } else if o == b { |
| 160 | // Ours is unchanged from base, so theirs owns this entry — |
| 161 | // a deletion included (`t == None`). |
| 162 | push_slot(&mut merged, name, t); |
| 163 | } else if t == b { |
| 164 | // Symmetric: theirs is unchanged, ours owns this entry. |
| 165 | push_slot(&mut merged, name, o); |
| 166 | } else { |
| 167 | // Both sides changed the same entry differently. A sub-tree on |
| 168 | // both sides is a nested struct or collection that can itself |
| 169 | // be merged field-by-field; anything else is a leaf conflict. |
| 170 | match (o, t) { |
| 171 | (Some(so), Some(st)) if so.is_tree() && st.is_tree() => { |
| 172 | let sub_base = b.filter(|s| s.is_tree()).map(|s| s.oid); |
| 173 | match three_way(objects, sub_base, so.oid, st.oid)? { |
| 174 | Merge::Clean(sub) => merged.push(TreeEntry { |
| 175 | mode: EntryKind::Tree.into(), |
| 176 | filename: name.clone(), |
| 177 | oid: sub, |
| 178 | }), |
| 179 | Merge::Conflict(paths) => { |
| 180 | for p in paths { |
| 181 | conflicts.push(join(name, &p)); |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | } |
| 186 | _ => conflicts.push(name.clone()), |
| 187 | } |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | if conflicts.is_empty() { |
| 192 | // git tree entries are canonically sorted; `TreeEntry`'s own `Ord` |
| 193 | // is that order, matching how `facet-git-tree` writes trees. |
| 194 | merged.sort(); |
| 195 | let oid = objects.write(&gix_object::Tree { entries: merged })?; |
| 196 | Ok(Merge::Clean(oid)) |
| 197 | } else { |
| 198 | conflicts.sort(); |
| 199 | Ok(Merge::Conflict(conflicts)) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | /// Read the entries of the tree at `oid` into a name-keyed map. |
| 204 | fn read_tree(objects: &impl Find, oid: ObjectId) -> Result<HashMap<BString, Slot>> { |
| 205 | let mut buf = Vec::new(); |
| 206 | let data = objects |
| 207 | .try_find(&oid, &mut buf) |
| 208 | .map_err(|source| Error::Object { oid, source })? |
| 209 | .ok_or(Error::Missing { oid })?; |
| 210 | let tree = TreeRef::from_bytes(data.data, oid.kind()).map_err(|e| Error::Decode { |
| 211 | oid, |
| 212 | detail: e.to_string(), |
| 213 | })?; |
| 214 | let mut map = HashMap::with_capacity(tree.entries.len()); |
| 215 | for entry in &tree.entries { |
| 216 | map.insert( |
| 217 | entry.filename.to_owned(), |
| 218 | Slot { |
| 219 | oid: entry.oid.to_owned(), |
| 220 | mode: entry.mode, |
| 221 | }, |
| 222 | ); |
| 223 | } |
| 224 | Ok(map) |
| 225 | } |
| 226 | |
| 227 | /// Append `slot` to `merged` under `name`, if it is present (a `None` slot |
| 228 | /// is an entry deleted on the winning side, so nothing is written). |
| 229 | fn push_slot(merged: &mut Vec<TreeEntry>, name: &BString, slot: Option<Slot>) { |
| 230 | if let Some(slot) = slot { |
| 231 | merged.push(TreeEntry { |
| 232 | mode: slot.mode, |
| 233 | filename: name.clone(), |
| 234 | oid: slot.oid, |
| 235 | }); |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | /// Join a parent entry name and a child path with `/`, the separator |
| 240 | /// `facet-git-tree` uses for nested tree paths. |
| 241 | fn join(parent: &BString, child: &BString) -> BString { |
| 242 | let mut path = parent.clone(); |
| 243 | path.push_char('/'); |
| 244 | path.push_str(child); |
| 245 | path |
| 246 | } |