max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+561 insertions,
-15 deletions
| @@ -14,12 +14,14 @@ | |||
| 14 | 14 | //! this is going (a standalone crate, extracted once the P3 merge strategies | |
| 15 | 15 | //! exist and give it something no other implementation has). | |
| 16 | 16 | //! | |
| 17 | - | //! The write path splices one note into a tree and hands the result back; the | |
| 18 | - | //! compare-and-swap that publishes it, and the merge strategies, follow. See | |
| 19 | - | //! "Splicing" below for why the shape of the tree we write is not arbitrary. | |
| 17 | + | //! The write path splices one note into a tree and publishes it by | |
| 18 | + | //! compare-and-swap; see "Splicing" below for why the shape of the tree we | |
| 19 | + | //! write is not arbitrary. Merging two notes refs is [`merge`], which carries | |
| 20 | + | //! git's own strategies byte-exact. | |
| 20 | 21 | ||
| 21 | 22 | pub mod engine; | |
| 22 | 23 | pub mod gix_engine; | |
| 24 | + | pub mod merge; | |
| 23 | 25 | ||
| 24 | 26 | use std::collections::{HashMap, HashSet}; | |
| 25 | 27 | use std::sync::Arc; | |
| @@ -28,6 +30,7 @@ | |||
| 28 | 30 | ||
| 29 | 31 | pub use engine::{CommitMeta, NewEntry, NoteObjects, NoteWrites, NotesError, Oid, Signature}; | |
| 30 | 32 | pub use gix_engine::GixEngine; | |
| 33 | + | pub use merge::{MergeStrategy, Merged, merge_notes}; | |
| 31 | 34 | ||
| 32 | 35 | use engine::{EntryKind, TreeEntry, Walk}; | |
| 33 | 36 | ||
| @@ -606,7 +609,7 @@ | |||
| 606 | 609 | /// so a concurrent writer is never overwritten — the loser reloads and tries | |
| 607 | 610 | /// again against what the winner left. Two people annotating different commits | |
| 608 | 611 | /// both land. Two people annotating the *same* commit get their notes merged | |
| 609 | - | /// (see [`union`]) rather than one silently replacing the other. | |
| 612 | + | /// (see [`merge_edit`]) rather than one silently replacing the other. | |
| 610 | 613 | pub fn write_note<E: NoteObjects + NoteWrites>( | |
| 611 | 614 | engine: &E, | |
| 612 | 615 | namespace: &str, | |
| @@ -644,7 +647,7 @@ | |||
| 644 | 647 | (true, Some(ours), Some(theirs)) => { | |
| 645 | 648 | buffer = Vec::new(); | |
| 646 | 649 | engine.read_blob_into(theirs, &mut buffer)?; | |
| 647 | - | buffer = union(&buffer, ours); | |
| 650 | + | buffer = merge_edit(&buffer, ours); | |
| 648 | 651 | Some(buffer.as_slice()) | |
| 649 | 652 | } | |
| 650 | 653 | _ => content, | |
| @@ -684,19 +687,19 @@ | |||
| 684 | 687 | Err(NotesError::Raced) | |
| 685 | 688 | } | |
| 686 | 689 | ||
| 687 | - | /// Combine two versions of the same note. | |
| 690 | + | /// Combine two versions of the same note after a lost ref race. | |
| 688 | 691 | /// | |
| 689 | 692 | /// Keeps what the writer typed intact, then appends any line of the other | |
| 690 | - | /// version it does not already contain. The shared text both versions started | |
| 691 | - | /// from is in `ours` already, which is why appending only the difference | |
| 692 | - | /// matters: a plain concatenation would double the paragraph neither of them | |
| 693 | - | /// touched. | |
| 693 | + | /// version it does not already contain. | |
| 694 | 694 | /// | |
| 695 | - | /// This is a stand-in with an honest name, not git's `union` strategy. The real | |
| 696 | - | /// set — `manual`, `ours`, `theirs`, `union`, `cat_sort_uniq`, byte-exact with | |
| 697 | - | /// git's — is P3, and this call site is where they land. Losing nothing is the | |
| 698 | - | /// property worth having until then. | |
| 699 | - | fn union(theirs: &[u8], ours: &[u8]) -> Vec<u8> { | |
| 695 | + | /// Deliberately **not** [`merge::MergeStrategy::Union`], which is byte-exact | |
| 696 | + | /// with git and concatenates both sides whole. The difference is that this | |
| 697 | + | /// caller knows the base: both writers loaded the same text and edited it, so | |
| 698 | + | /// the paragraph neither of them touched is present in both versions, and | |
| 699 | + | /// concatenating would print it twice. Git's strategies merge two refs that may | |
| 700 | + | /// share no history and cannot assume otherwise. Using the one named after | |
| 701 | + | /// git's here would produce a result git's own `union` would not. | |
| 702 | + | fn merge_edit(theirs: &[u8], ours: &[u8]) -> Vec<u8> { | |
| 700 | 703 | let mine: HashSet<&[u8]> = ours.split(|b| *b == b'\n').collect(); | |
| 701 | 704 | let extra: Vec<&[u8]> = theirs | |
| 702 | 705 | .split(|b| *b == b'\n') |
| @@ -730,6 +730,238 @@ | |||
| 730 | 730 | } | |
| 731 | 731 | } | |
| 732 | 732 | ||
| 733 | + | // ── Merging ── | |
| 734 | + | ||
| 735 | + | /// Build a notes tree holding `notes`, one splice at a time, so the fanout is | |
| 736 | + | /// whatever the writer would really have produced. | |
| 737 | + | fn tree_of(engine: &GixEngine<'_>, notes: &[(&str, &str)]) -> Option<Oid> { | |
| 738 | + | let mut tree = None; | |
| 739 | + | for (target, content) in notes { | |
| 740 | + | let blob = engine.write_blob(content.as_bytes()).unwrap(); | |
| 741 | + | tree = splice_note(engine, tree, oid(target), Some(blob)).unwrap(); | |
| 742 | + | } | |
| 743 | + | tree | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | /// The notes in a tree as target-to-text, for comparing merge results. | |
| 747 | + | fn contents(engine: &GixEngine<'_>, tree: Option<Oid>) -> Vec<(String, String)> { | |
| 748 | + | let Some(tree) = tree else { return Vec::new() }; | |
| 749 | + | let mut out = Vec::new(); | |
| 750 | + | for (target, blob) in { | |
| 751 | + | let mut all = Vec::new(); | |
| 752 | + | flatten(engine, tree, &mut |t, b| all.push((t, b))).unwrap(); | |
| 753 | + | all.sort_unstable(); | |
| 754 | + | all | |
| 755 | + | } { | |
| 756 | + | let mut bytes = Vec::new(); | |
| 757 | + | engine.read_blob_into(blob, &mut bytes).unwrap(); | |
| 758 | + | out.push((target.to_hex(), String::from_utf8(bytes).unwrap())); | |
| 759 | + | } | |
| 760 | + | out | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | #[test] | |
| 764 | + | fn a_note_only_one_side_touched_is_taken_without_consulting_the_strategy() { | |
| 765 | + | let (_tmp, repo) = init_bare(); | |
| 766 | + | let engine = GixEngine::new(&repo); | |
| 767 | + | ||
| 768 | + | let base = tree_of(&engine, &[(T1, "shared\n")]); | |
| 769 | + | // They added T2; we added T3. Nothing collides. | |
| 770 | + | let theirs = tree_of(&engine, &[(T1, "shared\n"), (T2, "theirs\n")]); | |
| 771 | + | let ours = tree_of(&engine, &[(T1, "shared\n"), (T3, "ours\n")]); | |
| 772 | + | ||
| 773 | + | // Manual is the strategy that resolves nothing, so using it here proves the | |
| 774 | + | // strategy was never reached: an ordinary push has to stay cheap. | |
| 775 | + | let merged = merge_notes(&engine, ours, theirs, base, MergeStrategy::Manual).unwrap(); | |
| 776 | + | assert!(merged.conflicts.is_empty(), "{:?}", merged.conflicts); | |
| 777 | + | assert!(merged.changed); | |
| 778 | + | assert_eq!( | |
| 779 | + | contents(&engine, merged.tree), | |
| 780 | + | vec![ | |
| 781 | + | (T2.to_string(), "theirs\n".to_string()), | |
| 782 | + | (T1.to_string(), "shared\n".to_string()), | |
| 783 | + | (T3.to_string(), "ours\n".to_string()), | |
| 784 | + | ] | |
| 785 | + | .into_iter() | |
| 786 | + | .collect::<std::collections::BTreeMap<_, _>>() | |
| 787 | + | .into_iter() | |
| 788 | + | .collect::<Vec<_>>() | |
| 789 | + | ); | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | #[test] | |
| 793 | + | fn a_deletion_only_one_side_made_is_honoured() { | |
| 794 | + | let (_tmp, repo) = init_bare(); | |
| 795 | + | let engine = GixEngine::new(&repo); | |
| 796 | + | ||
| 797 | + | let base = tree_of(&engine, &[(T1, "note\n"), (T2, "other\n")]); | |
| 798 | + | let theirs = tree_of(&engine, &[(T2, "other\n")]); // they removed T1 | |
| 799 | + | let ours = base; | |
| 800 | + | ||
| 801 | + | let merged = merge_notes(&engine, ours, theirs, base, MergeStrategy::Manual).unwrap(); | |
| 802 | + | assert!(merged.conflicts.is_empty()); | |
| 803 | + | assert_eq!( | |
| 804 | + | contents(&engine, merged.tree), | |
| 805 | + | vec![(T2.to_string(), "other\n".to_string())], | |
| 806 | + | "a delete nobody contested has to survive the merge" | |
| 807 | + | ); | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | #[test] | |
| 811 | + | fn an_identical_change_on_both_sides_is_not_a_merge_at_all() { | |
| 812 | + | let (_tmp, repo) = init_bare(); | |
| 813 | + | let engine = GixEngine::new(&repo); | |
| 814 | + | ||
| 815 | + | let base = tree_of(&engine, &[(T1, "old\n")]); | |
| 816 | + | // Content addressing means the same text is the same blob, so two people | |
| 817 | + | // typing the same thing is not a conflict and must not become a commit. | |
| 818 | + | let ours = tree_of(&engine, &[(T1, "new\n")]); | |
| 819 | + | let theirs = tree_of(&engine, &[(T1, "new\n")]); | |
| 820 | + | ||
| 821 | + | let merged = merge_notes(&engine, ours, theirs, base, MergeStrategy::Manual).unwrap(); | |
| 822 | + | assert!(merged.conflicts.is_empty()); | |
| 823 | + | assert!( | |
| 824 | + | !merged.changed, | |
| 825 | + | "nothing moved, so there is nothing to commit" | |
| 826 | + | ); | |
| 827 | + | assert_eq!(merged.tree, ours); | |
| 828 | + | } | |
| 829 | + | ||
| 830 | + | #[test] | |
| 831 | + | fn each_strategy_resolves_a_real_conflict_its_own_way() { | |
| 832 | + | let (_tmp, repo) = init_bare(); | |
| 833 | + | let engine = GixEngine::new(&repo); | |
| 834 | + | ||
| 835 | + | let base = tree_of(&engine, &[(T1, "old\n")]); | |
| 836 | + | let ours = tree_of(&engine, &[(T1, "our version\n")]); | |
| 837 | + | let theirs = tree_of(&engine, &[(T1, "their version\n")]); | |
| 838 | + | ||
| 839 | + | let cases = [ | |
| 840 | + | (MergeStrategy::Ours, "our version\n"), | |
| 841 | + | (MergeStrategy::Theirs, "their version\n"), | |
| 842 | + | (MergeStrategy::Union, "our version\n\ntheir version\n"), | |
| 843 | + | (MergeStrategy::CatSortUniq, "our version\ntheir version\n"), | |
| 844 | + | ]; | |
| 845 | + | for (strategy, expected) in cases { | |
| 846 | + | let merged = merge_notes(&engine, ours, theirs, base, strategy).unwrap(); | |
| 847 | + | assert!(merged.conflicts.is_empty(), "{strategy:?}"); | |
| 848 | + | assert_eq!( | |
| 849 | + | contents(&engine, merged.tree), | |
| 850 | + | vec![(T1.to_string(), expected.to_string())], | |
| 851 | + | "{strategy:?}" | |
| 852 | + | ); | |
| 853 | + | assert_eq!( | |
| 854 | + | merged.changed, | |
| 855 | + | strategy != MergeStrategy::Ours, | |
| 856 | + | "{strategy:?}" | |
| 857 | + | ); | |
| 858 | + | } | |
| 859 | + | ||
| 860 | + | // Manual changes nothing and hands the target back instead. | |
| 861 | + | let merged = merge_notes(&engine, ours, theirs, base, MergeStrategy::Manual).unwrap(); | |
| 862 | + | assert_eq!(merged.conflicts, vec![oid(T1)]); | |
| 863 | + | assert!(!merged.changed); | |
| 864 | + | assert_eq!(merged.tree, ours); | |
| 865 | + | } | |
| 866 | + | ||
| 867 | + | #[test] | |
| 868 | + | fn merging_an_unrelated_history_makes_every_shared_target_a_conflict() { | |
| 869 | + | let (_tmp, repo) = init_bare(); | |
| 870 | + | let engine = GixEngine::new(&repo); | |
| 871 | + | ||
| 872 | + | // The shape the notes inbox produces on purpose: the incoming tip shares no | |
| 873 | + | // history, so there is no base and the strategy decides everything both | |
| 874 | + | // sides carry. Chosen at the door, which is why the inbox can promise a | |
| 875 | + | // push never fails. | |
| 876 | + | let ours = tree_of(&engine, &[(T1, "ours\n"), (T2, "only ours\n")]); | |
| 877 | + | let theirs = tree_of(&engine, &[(T1, "theirs\n"), (T3, "only theirs\n")]); | |
| 878 | + | ||
| 879 | + | let merged = merge_notes(&engine, ours, theirs, None, MergeStrategy::CatSortUniq).unwrap(); | |
| 880 | + | assert!(merged.conflicts.is_empty()); | |
| 881 | + | assert_eq!( | |
| 882 | + | contents(&engine, merged.tree), | |
| 883 | + | vec![ | |
| 884 | + | (T2.to_string(), "only ours\n".to_string()), | |
| 885 | + | (T1.to_string(), "ours\ntheirs\n".to_string()), | |
| 886 | + | (T3.to_string(), "only theirs\n".to_string()), | |
| 887 | + | ] | |
| 888 | + | .into_iter() | |
| 889 | + | .collect::<std::collections::BTreeMap<_, _>>() | |
| 890 | + | .into_iter() | |
| 891 | + | .collect::<Vec<_>>() | |
| 892 | + | ); | |
| 893 | + | } | |
| 894 | + | ||
| 895 | + | #[test] | |
| 896 | + | fn combining_never_deletes_the_other_sides_note() { | |
| 897 | + | let (_tmp, repo) = init_bare(); | |
| 898 | + | let engine = GixEngine::new(&repo); | |
| 899 | + | ||
| 900 | + | // One side removed the note, the other rewrote it. Resolving that by | |
| 901 | + | // deleting would lose the only version anybody had something to say in, | |
| 902 | + | // and "a note push never loses a note" is what the inbox promises. | |
| 903 | + | let base = tree_of(&engine, &[(T1, "old\n")]); | |
| 904 | + | let ours = tree_of(&engine, &[(T1, "kept and edited\n")]); | |
| 905 | + | let theirs: Option<Oid> = None; | |
| 906 | + | ||
| 907 | + | for strategy in [MergeStrategy::Union, MergeStrategy::CatSortUniq] { | |
| 908 | + | let merged = merge_notes(&engine, ours, theirs, base, strategy).unwrap(); | |
| 909 | + | assert_eq!( | |
| 910 | + | contents(&engine, merged.tree), | |
| 911 | + | vec![(T1.to_string(), "kept and edited\n".to_string())], | |
| 912 | + | "{strategy:?}" | |
| 913 | + | ); | |
| 914 | + | } | |
| 915 | + | } | |
| 916 | + | ||
| 917 | + | #[test] | |
| 918 | + | fn a_merge_into_an_unborn_namespace_takes_everything() { | |
| 919 | + | let (_tmp, repo) = init_bare(); | |
| 920 | + | let engine = GixEngine::new(&repo); | |
| 921 | + | ||
| 922 | + | let theirs = tree_of(&engine, &[(T1, "first\n"), (T3, "second\n")]); | |
| 923 | + | let merged = merge_notes(&engine, None, theirs, None, MergeStrategy::CatSortUniq).unwrap(); | |
| 924 | + | ||
| 925 | + | assert!(merged.changed); | |
| 926 | + | assert_eq!(contents(&engine, merged.tree), contents(&engine, theirs)); | |
| 927 | + | } | |
| 928 | + | ||
| 929 | + | #[test] | |
| 930 | + | fn a_merged_tree_reads_back_through_the_reader() { | |
| 931 | + | let (_tmp, repo) = init_bare(); | |
| 932 | + | let engine = GixEngine::new(&repo); | |
| 933 | + | ||
| 934 | + | // T1 and T2 collide on `aabb`, so the merged tree carries real fanout and | |
| 935 | + | // the reader has to agree with what the merge wrote. | |
| 936 | + | let ours = tree_of(&engine, &[(T1, "ours\n")]); | |
| 937 | + | let theirs = tree_of(&engine, &[(T1, "theirs\n"), (T2, "second\n")]); | |
| 938 | + | let merged = merge_notes(&engine, ours, theirs, None, MergeStrategy::Union).unwrap(); | |
| 939 | + | ||
| 940 | + | let who = signature("Max"); | |
| 941 | + | let commit = engine | |
| 942 | + | .write_commit(merged.tree.unwrap(), &[], &who, &who, "notes: merge\n") | |
| 943 | + | .unwrap(); | |
| 944 | + | engine | |
| 945 | + | .update_ref_cas("refs/notes/commits", None, commit) | |
| 946 | + | .unwrap(); | |
| 947 | + | ||
| 948 | + | let ns = namespace(&engine, DEFAULT_NAMESPACE); | |
| 949 | + | assert_eq!( | |
| 950 | + | note_for(&engine, ns.tip, oid(T1)) | |
| 951 | + | .unwrap() | |
| 952 | + | .unwrap() | |
| 953 | + | .content_lossy(), | |
| 954 | + | "ours\n\ntheirs\n" | |
| 955 | + | ); | |
| 956 | + | assert_eq!( | |
| 957 | + | note_for(&engine, ns.tip, oid(T2)) | |
| 958 | + | .unwrap() | |
| 959 | + | .unwrap() | |
| 960 | + | .content_lossy(), | |
| 961 | + | "second\n" | |
| 962 | + | ); | |
| 963 | + | } | |
| 964 | + | ||
| 733 | 965 | // ── Publishing under contention ── | |
| 734 | 966 | ||
| 735 | 967 | /// An engine that lets somebody else publish first. |
| @@ -1,0 +1,311 @@ | |||
| 1 | + | //! Merging two notes refs, byte-exact with git's own strategies. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: mnw-server-git-notes --> | |
| 4 | + | //! | |
| 5 | + | //! This is the part no other implementation has. `gix` has no notes API at all; | |
| 6 | + | //! `libgit2` has the primitives (`note`, `note_delete`) and none of the merge | |
| 7 | + | //! strategies. `git notes merge --strategy=cat_sort_uniq` is the recovery every | |
| 8 | + | //! concurrent notes writer is eventually told to run, and it exists in exactly | |
| 9 | + | //! one place: the git binary. | |
| 10 | + | //! | |
| 11 | + | //! Byte-exact matters here in a way it rarely does. These strategies carry | |
| 12 | + | //! git's names, and a `union` that concatenated differently from git's `union` | |
| 13 | + | //! would produce a different blob, a different tree, and a different commit for | |
| 14 | + | //! the same inputs — so a merge run on the server and the same merge run by a | |
| 15 | + | //! client would disagree about what the notes ref should say. The two | |
| 16 | + | //! `combine_*` functions below are transcriptions of `notes.c`, not | |
| 17 | + | //! reinterpretations of it, and the tests pin the details worth getting wrong: | |
| 18 | + | //! the single trailing newline `union` strips, the blank-line separator it | |
| 19 | + | //! inserts, and `cat_sort_uniq`'s empty-line removal happening before the sort. | |
| 20 | + | ||
| 21 | + | use std::collections::{HashMap, HashSet}; | |
| 22 | + | ||
| 23 | + | use super::engine::{NoteObjects, NoteWrites, NotesError, Oid}; | |
| 24 | + | use super::{flatten, splice_note}; | |
| 25 | + | ||
| 26 | + | /// How to resolve a target both sides changed. | |
| 27 | + | /// | |
| 28 | + | /// Git's five, under git's names. A target only one side touched is not a | |
| 29 | + | /// conflict and never reaches the strategy — see [`merge_notes`]. | |
| 30 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 31 | + | pub enum MergeStrategy { | |
| 32 | + | /// Change nothing and report the conflicts. Git drops the user into a | |
| 33 | + | /// worktree here; a server has no worktree and no user to drop, so the | |
| 34 | + | /// caller gets the list and decides. | |
| 35 | + | Manual, | |
| 36 | + | /// Keep the local version. | |
| 37 | + | Ours, | |
| 38 | + | /// Take the incoming version. | |
| 39 | + | Theirs, | |
| 40 | + | /// Both, concatenated, separated by a blank line. | |
| 41 | + | Union, | |
| 42 | + | /// Every distinct non-empty line from both, sorted. What the folklore | |
| 43 | + | /// recovery for a rejected notes push actually runs. | |
| 44 | + | CatSortUniq, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// What a merge produced. | |
| 48 | + | #[derive(Debug, Clone)] | |
| 49 | + | pub struct Merged { | |
| 50 | + | /// The merged root tree. Equal to the local tree when nothing changed. | |
| 51 | + | pub tree: Option<Oid>, | |
| 52 | + | /// Targets whose notes differ on both sides and were left alone. Only | |
| 53 | + | /// [`MergeStrategy::Manual`] produces these; every other strategy resolves | |
| 54 | + | /// by definition. | |
| 55 | + | pub conflicts: Vec<Oid>, | |
| 56 | + | /// Whether the merged tree differs from the local one. A merge that changed | |
| 57 | + | /// nothing must not become a commit. | |
| 58 | + | pub changed: bool, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Merge the notes tree `theirs` into `ours`, relative to `base`. | |
| 62 | + | /// | |
| 63 | + | /// All three are root trees, `None` meaning absent — an unborn namespace for | |
| 64 | + | /// `ours` or `theirs`, and for `base` the ordinary case of two histories with | |
| 65 | + | /// nothing in common. That last one is not a degenerate input here: the notes | |
| 66 | + | /// inbox is deliberately an unrelated tip, so every target both sides carry is | |
| 67 | + | /// a conflict and the strategy decides all of them. Which is the point of | |
| 68 | + | /// choosing the strategy at the door rather than discovering a conflict later. | |
| 69 | + | /// | |
| 70 | + | /// Per target, before any strategy runs: | |
| 71 | + | /// | |
| 72 | + | /// - both sides agree: nothing to do. | |
| 73 | + | /// - only one side moved away from the base: take that side, including when | |
| 74 | + | /// the move was a deletion. This is what keeps an ordinary push a | |
| 75 | + | /// fast-forward per note rather than a merge of everything. | |
| 76 | + | /// - both moved, differently: the strategy decides. | |
| 77 | + | pub fn merge_notes<E: NoteObjects + NoteWrites>( | |
| 78 | + | engine: &E, | |
| 79 | + | ours: Option<Oid>, | |
| 80 | + | theirs: Option<Oid>, | |
| 81 | + | base: Option<Oid>, | |
| 82 | + | strategy: MergeStrategy, | |
| 83 | + | ) -> Result<Merged, NotesError> { | |
| 84 | + | let ours_notes = read_map(engine, ours)?; | |
| 85 | + | let theirs_notes = read_map(engine, theirs)?; | |
| 86 | + | let base_notes = read_map(engine, base)?; | |
| 87 | + | ||
| 88 | + | let targets: HashSet<Oid> = ours_notes | |
| 89 | + | .keys() | |
| 90 | + | .chain(theirs_notes.keys()) | |
| 91 | + | .copied() | |
| 92 | + | .collect(); | |
| 93 | + | ||
| 94 | + | // Sorted so a merge is reproducible: the same inputs have to produce the | |
| 95 | + | // same tree, and applying the changes in map order would not. | |
| 96 | + | let mut targets: Vec<Oid> = targets.into_iter().collect(); | |
| 97 | + | targets.sort_unstable(); | |
| 98 | + | ||
| 99 | + | let mut tree = ours; | |
| 100 | + | let mut conflicts = Vec::new(); | |
| 101 | + | let mut changed = false; | |
| 102 | + | ||
| 103 | + | for target in targets { | |
| 104 | + | let ours_blob = ours_notes.get(&target).copied(); | |
| 105 | + | let theirs_blob = theirs_notes.get(&target).copied(); | |
| 106 | + | let base_blob = base_notes.get(&target).copied(); | |
| 107 | + | ||
| 108 | + | let resolved = match resolve(engine, ours_blob, theirs_blob, base_blob, strategy)? { | |
| 109 | + | Resolution::Keep => continue, | |
| 110 | + | Resolution::Conflict => { | |
| 111 | + | conflicts.push(target); | |
| 112 | + | continue; | |
| 113 | + | } | |
| 114 | + | Resolution::Take(blob) => blob, | |
| 115 | + | }; | |
| 116 | + | ||
| 117 | + | if let Some(spliced) = splice_note(engine, tree, target, resolved)? { | |
| 118 | + | tree = Some(spliced); | |
| 119 | + | changed = true; | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | Ok(Merged { | |
| 124 | + | tree, | |
| 125 | + | conflicts, | |
| 126 | + | changed, | |
| 127 | + | }) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | enum Resolution { | |
| 131 | + | /// The local side already says the right thing. | |
| 132 | + | Keep, | |
| 133 | + | /// Write this (`None` removes the note). | |
| 134 | + | Take(Option<Oid>), | |
| 135 | + | /// Unresolvable under this strategy. | |
| 136 | + | Conflict, | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | fn resolve<E: NoteObjects + NoteWrites>( | |
| 140 | + | engine: &E, | |
| 141 | + | ours: Option<Oid>, | |
| 142 | + | theirs: Option<Oid>, | |
| 143 | + | base: Option<Oid>, | |
| 144 | + | strategy: MergeStrategy, | |
| 145 | + | ) -> Result<Resolution, NotesError> { | |
| 146 | + | if ours == theirs { | |
| 147 | + | return Ok(Resolution::Keep); | |
| 148 | + | } | |
| 149 | + | // Only they moved: take their side, deletion included. | |
| 150 | + | if ours == base { | |
| 151 | + | return Ok(Resolution::Take(theirs)); | |
| 152 | + | } | |
| 153 | + | // Only we moved: keep ours. | |
| 154 | + | if theirs == base { | |
| 155 | + | return Ok(Resolution::Keep); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | match strategy { | |
| 159 | + | MergeStrategy::Manual => Ok(Resolution::Conflict), | |
| 160 | + | MergeStrategy::Ours => Ok(Resolution::Keep), | |
| 161 | + | MergeStrategy::Theirs => Ok(Resolution::Take(theirs)), | |
| 162 | + | MergeStrategy::Union | MergeStrategy::CatSortUniq => { | |
| 163 | + | // A side that deleted contributes nothing rather than deleting the | |
| 164 | + | // other side's work. Combining is what these two strategies are | |
| 165 | + | // for, and "a note push never loses a note" is the promise the | |
| 166 | + | // inbox is built on; resolving delete-versus-edit by deleting would | |
| 167 | + | // break it in the one case where somebody had something to say. | |
| 168 | + | let (Some(ours_blob), Some(theirs_blob)) = (ours, theirs) else { | |
| 169 | + | return Ok(Resolution::Take(ours.or(theirs))); | |
| 170 | + | }; | |
| 171 | + | ||
| 172 | + | let mut ours_bytes = Vec::new(); | |
| 173 | + | engine.read_blob_into(ours_blob, &mut ours_bytes)?; | |
| 174 | + | let mut theirs_bytes = Vec::new(); | |
| 175 | + | engine.read_blob_into(theirs_blob, &mut theirs_bytes)?; | |
| 176 | + | ||
| 177 | + | let combined = match strategy { | |
| 178 | + | MergeStrategy::CatSortUniq => cat_sort_uniq(&ours_bytes, &theirs_bytes), | |
| 179 | + | _ => union(&ours_bytes, &theirs_bytes), | |
| 180 | + | }; | |
| 181 | + | Ok(Resolution::Take(Some(engine.write_blob(&combined)?))) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// Both notes, in order, separated by a blank line. | |
| 187 | + | /// | |
| 188 | + | /// Transcribed from `combine_notes_concatenate` in git's `notes.c`. The two | |
| 189 | + | /// details that are not obvious and are not decoration: exactly one trailing | |
| 190 | + | /// newline is stripped from the local side before joining (so the separator is | |
| 191 | + | /// a blank line rather than a stray third newline), and an empty side yields | |
| 192 | + | /// the other side untouched rather than a leading or trailing blank. | |
| 193 | + | fn union(ours: &[u8], theirs: &[u8]) -> Vec<u8> { | |
| 194 | + | if theirs.is_empty() { | |
| 195 | + | return ours.to_vec(); | |
| 196 | + | } | |
| 197 | + | if ours.is_empty() { | |
| 198 | + | return theirs.to_vec(); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | let ours = match ours.split_last() { | |
| 202 | + | Some((b'\n', rest)) => rest, | |
| 203 | + | _ => ours, | |
| 204 | + | }; | |
| 205 | + | ||
| 206 | + | let mut out = Vec::with_capacity(ours.len() + 2 + theirs.len()); | |
| 207 | + | out.extend_from_slice(ours); | |
| 208 | + | out.extend_from_slice(b"\n\n"); | |
| 209 | + | out.extend_from_slice(theirs); | |
| 210 | + | out | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// Every distinct non-empty line from both notes, sorted, one per line. | |
| 214 | + | /// | |
| 215 | + | /// Transcribed from `combine_notes_cat_sort_uniq` in git's `notes.c`. Empty | |
| 216 | + | /// lines go before the sort rather than after, which is why the result never | |
| 217 | + | /// carries a blank line however the inputs were spaced, and the sort is | |
| 218 | + | /// bytewise because git's is `strcmp`. The output always ends in a newline. | |
| 219 | + | /// | |
| 220 | + | /// The ordering is the price: this is the right strategy for machine-written | |
| 221 | + | /// notes, where each line stands alone, and the wrong one for a paragraph. | |
| 222 | + | fn cat_sort_uniq(ours: &[u8], theirs: &[u8]) -> Vec<u8> { | |
| 223 | + | let mut lines: Vec<&[u8]> = ours | |
| 224 | + | .split(|b| *b == b'\n') | |
| 225 | + | .chain(theirs.split(|b| *b == b'\n')) | |
| 226 | + | .filter(|line| !line.is_empty()) | |
| 227 | + | .collect(); | |
| 228 | + | lines.sort_unstable(); | |
| 229 | + | lines.dedup(); | |
| 230 | + | ||
| 231 | + | let mut out = Vec::new(); | |
| 232 | + | for line in lines { | |
| 233 | + | out.extend_from_slice(line); | |
| 234 | + | out.push(b'\n'); | |
| 235 | + | } | |
| 236 | + | out | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | /// A notes tree as a target-to-blob map. An absent tree is an empty map, which | |
| 240 | + | /// is what makes an unborn namespace and an unrelated history the same case. | |
| 241 | + | fn read_map<E: NoteObjects>( | |
| 242 | + | engine: &E, | |
| 243 | + | tree: Option<Oid>, | |
| 244 | + | ) -> Result<HashMap<Oid, Oid>, NotesError> { | |
| 245 | + | let mut map = HashMap::new(); | |
| 246 | + | if let Some(tree) = tree { | |
| 247 | + | flatten(engine, tree, &mut |target, blob| { | |
| 248 | + | map.insert(target, blob); | |
| 249 | + | })?; | |
| 250 | + | } | |
| 251 | + | Ok(map) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | #[cfg(test)] | |
| 255 | + | mod tests { | |
| 256 | + | use super::*; | |
| 257 | + | ||
| 258 | + | // Byte-exactness is asserted on the combine functions directly. Going | |
| 259 | + | // through a repository would prove the same thing more slowly and would | |
| 260 | + | // stop reading as a statement about the format. | |
| 261 | + | ||
| 262 | + | #[test] | |
| 263 | + | fn union_separates_with_exactly_one_blank_line() { | |
| 264 | + | // The local side's single trailing newline is consumed by the | |
| 265 | + | // separator. Getting this wrong yields three newlines and a blob that | |
| 266 | + | // differs from git's for the same input. | |
| 267 | + | assert_eq!(union(b"ours\n", b"theirs\n"), b"ours\n\ntheirs\n"); | |
| 268 | + | assert_eq!(union(b"ours", b"theirs"), b"ours\n\ntheirs"); | |
| 269 | + | // Only one newline is stripped, not the run. | |
| 270 | + | assert_eq!(union(b"ours\n\n", b"theirs"), b"ours\n\n\ntheirs"); | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | #[test] | |
| 274 | + | fn union_of_one_side_is_that_side_untouched() { | |
| 275 | + | assert_eq!(union(b"ours\n", b""), b"ours\n"); | |
| 276 | + | assert_eq!(union(b"", b"theirs\n"), b"theirs\n"); | |
| 277 | + | assert_eq!(union(b"", b""), b""); | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | #[test] | |
| 281 | + | fn union_keeps_both_sides_in_order() { | |
| 282 | + | let merged = union(b"first line\nsecond line\n", b"their line\n"); | |
| 283 | + | assert_eq!(merged, b"first line\nsecond line\n\ntheir line\n"); | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | #[test] | |
| 287 | + | fn cat_sort_uniq_sorts_dedupes_and_drops_blank_lines() { | |
| 288 | + | let merged = cat_sort_uniq(b"b\na\n\nc\n", b"c\n\nd\n"); | |
| 289 | + | assert_eq!(merged, b"a\nb\nc\nd\n"); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | #[test] | |
| 293 | + | fn cat_sort_uniq_always_ends_in_a_newline() { | |
| 294 | + | assert_eq!(cat_sort_uniq(b"only", b""), b"only\n"); | |
| 295 | + | // Both sides empty is the one case with nothing to terminate. | |
| 296 | + | assert_eq!(cat_sort_uniq(b"", b""), b""); | |
| 297 | + | assert_eq!(cat_sort_uniq(b"\n\n\n", b"\n"), b""); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | #[test] | |
| 301 | + | fn cat_sort_uniq_sorts_bytewise_like_strcmp() { | |
| 302 | + | // Uppercase before lowercase, digits before both. A locale-aware sort | |
| 303 | + | // would order these differently and produce a different blob. | |
| 304 | + | let merged = cat_sort_uniq(b"apple\nBanana\n", b"10\nZebra\n"); | |
| 305 | + | assert_eq!(merged, b"10\nBanana\nZebra\napple\n"); | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // The decision table and the tree rewriting are exercised against a real | |
| 309 | + | // repository in `super::tests`, where a merge can be read back through the | |
| 310 | + | // reader that has to agree with it. | |
| 311 | + | } |