Skip to main content

max / makenotwork

server: merge two notes refs with git's own strategies The semantics layer nobody else has. gix has no notes API; libgit2 has the primitives and none of the merge strategies; `git notes merge --strategy=cat_sort_uniq` is the recovery every concurrent notes writer is eventually told to run, and it exists in exactly one place, the git binary. This is P3's foundation: the inbox that makes a note push never fail is a union merge on the server, so the merge has to exist first. Byte-exact is the requirement, not an aspiration. These carry git's names, and a `union` that joined differently would produce a different blob, tree and commit for the same inputs, so a merge run here and the same merge run by a client would disagree about what the ref should say. Both combine functions are transcriptions of notes.c: union strips exactly one trailing newline from the local side before inserting a blank-line separator, and cat_sort_uniq drops empty lines before the sort, sorts bytewise like strcmp, and always terminates the last line. The three-way part decides what never reaches a strategy. Sides that agree, and targets only one side moved away from the base, resolve without one, deletions included. That keeps an ordinary push a fast-forward per note. Only a target both sides changed differently is a conflict. With no merge base every shared target is a conflict by construction, which is the inbox's shape on purpose rather than a degenerate input. Union and cat_sort_uniq treat a delete-versus-edit conflict as the edit. A side that deleted has nothing to contribute, and resolving by deletion would break "never loses a note" in the one case where somebody had something to say. Manual changes nothing and reports the targets. Git drops the user into a worktree there; a server has neither worktree nor user, so the caller decides. The stand-in the P2 write path uses is renamed `merge_edit` and its doc now says why it is not the `union` next to it: it knows the base, so both versions already contain the text neither writer touched, and concatenating whole would print that twice. Git's strategies merge refs that may share no history and cannot assume otherwise.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 21:24 UTC
Signed with PGP, not checked
Commit: f3e4f0901b3d1cc4255cfcff345b09dfc41c82ea
Parent: ee4e871
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 + }