1use std::collections::{BTreeMap, BTreeSet, HashMap};
17use std::fs;
18
19use time::{OffsetDateTime, UtcOffset};
20
21use crate::commit::assemble_identity;
22use crate::config::ConfigSet;
23use crate::error::{Error, Result};
24use crate::ident::parse_signature_times;
25use crate::ident_resolve::{IdentRole, IdentityEnv};
26use crate::merge_base::merge_bases_first_vs_rest;
27use crate::objects::{
28 parse_commit, parse_tree, serialize_commit, serialize_tree, tree_entry_cmp, CommitData,
29 ObjectId, ObjectKind, TreeEntry,
30};
31use crate::refs::should_autocreate_reflog_with_config;
32use crate::refs::store::{Expected, RawRef, ReflogUpdate};
33use crate::refs::store_write_value_on_store;
34use crate::repo::Repository;
35use crate::rev_parse::resolve_revision;
36use merge3::{Merge3, StandardMarkers};
37
38const NOTES_MERGE_WORKTREE: &str = "NOTES_MERGE_WORKTREE";
40
41#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct NotesTreeEntry {
44 pub mode: u32,
46 pub path: Vec<u8>,
48 pub oid: ObjectId,
50}
51
52type NoteBlobChange = (ObjectId, Option<ObjectId>, Option<ObjectId>);
54
55enum NotesTreeChild {
56 Blob {
57 mode: u32,
59 oid: ObjectId,
61 },
62 Tree(Vec<NotesTreeEntry>),
63}
64
65pub fn note_object_name(path: &[u8]) -> Option<String> {
67 let compact: Vec<u8> = path.iter().copied().filter(|byte| *byte != b'/').collect();
68 if !ObjectId::is_hex_len(compact.len()) || !compact.iter().all(u8::is_ascii_hexdigit) {
69 return None;
70 }
71 String::from_utf8(compact)
72 .ok()
73 .map(|name| name.to_ascii_lowercase())
74}
75
76fn collect_notes_tree_entries(
77 repo: &Repository,
78 tree_oid: &ObjectId,
79 prefix: &[u8],
80 out: &mut Vec<NotesTreeEntry>,
81 max_listable_notes: Option<usize>,
82 listable_seen: &mut usize,
83 truncated: &mut bool,
84) -> Result<()> {
85 if max_listable_notes.is_some_and(|max| *listable_seen >= max) {
86 *truncated = true;
87 return Ok(());
88 }
89 let tree_obj = repo.odb().read(tree_oid)?;
90 if tree_obj.kind != ObjectKind::Tree {
91 return Err(Error::NotesObjectKind {
92 oid: *tree_oid,
93 expected: ObjectKind::Tree,
94 found: tree_obj.kind,
95 });
96 }
97
98 for entry in parse_tree(&tree_obj.data)? {
99 if max_listable_notes.is_some_and(|max| *listable_seen >= max) {
100 *truncated = true;
101 return Ok(());
102 }
103 let mut path = prefix.to_vec();
104 if !path.is_empty() {
105 path.push(b'/');
106 }
107 path.extend_from_slice(&entry.name);
108
109 if entry.mode == 0o040000 {
110 collect_notes_tree_entries(
111 repo,
112 &entry.oid,
113 &path,
114 out,
115 max_listable_notes,
116 listable_seen,
117 truncated,
118 )?;
119 if *truncated {
120 return Ok(());
121 }
122 } else {
123 let counts = note_object_name(&path).is_some();
124 if max_listable_notes.is_none() || counts {
125 out.push(NotesTreeEntry {
126 mode: entry.mode,
127 path,
128 oid: entry.oid,
129 });
130 }
131 if counts {
132 *listable_seen += 1;
133 if max_listable_notes.is_some_and(|max| *listable_seen >= max) {
134 *truncated = true;
135 return Ok(());
136 }
137 }
138 }
139 }
140
141 Ok(())
142}
143
144fn notes_ref_tip(repo: &Repository, notes_ref: &str) -> Result<Option<ObjectId>> {
146 match repo.refs().read_raw(notes_ref)? {
147 None => Ok(None),
148 Some(RawRef::Direct(oid)) => Ok(Some(oid)),
149 Some(RawRef::Symbolic(_)) => repo.refs().resolve(notes_ref).map(Some),
150 }
151}
152
153pub fn read_notes_tree(repo: &Repository, notes_ref: &str) -> Result<Vec<NotesTreeEntry>> {
167 Ok(read_notes_snapshot(repo, notes_ref)?.entries)
168}
169
170pub(crate) struct NotesSnapshot {
172 pub(crate) tip: Option<ObjectId>,
175 pub(crate) entries: Vec<NotesTreeEntry>,
177}
178
179pub(crate) fn read_notes_snapshot(repo: &Repository, notes_ref: &str) -> Result<NotesSnapshot> {
181 let ref_tip = notes_ref_tip(repo, notes_ref)?;
182 let tip = match ref_tip {
183 Some(oid) => oid,
184 None => match resolve_revision(repo, notes_ref) {
185 Ok(oid) => oid,
186 Err(_) => {
187 return Ok(NotesSnapshot {
188 tip: None,
189 entries: Vec::new(),
190 })
191 }
192 },
193 };
194 let obj = repo.odb().read(&tip)?;
195 let tree_oid = match obj.kind {
196 ObjectKind::Commit => parse_commit(&obj.data)?.tree,
197 ObjectKind::Tree => tip,
198 found => {
199 return Err(Error::NotesRefNotCommit {
200 notes_ref: notes_ref.to_owned(),
201 found,
202 })
203 }
204 };
205 let mut entries = Vec::new();
206 let mut listable_seen = 0usize;
207 let mut truncated = false;
208 collect_notes_tree_entries(
209 repo,
210 &tree_oid,
211 b"",
212 &mut entries,
213 None,
214 &mut listable_seen,
215 &mut truncated,
216 )?;
217 debug_assert!(!truncated, "unlimited notes collect must not truncate");
218 Ok(NotesSnapshot {
219 tip: ref_tip,
220 entries,
221 })
222}
223
224pub fn read_notes_tree_limited(
231 repo: &Repository,
232 notes_ref: &str,
233 max_entries: Option<usize>,
234) -> Result<(Vec<NotesTreeEntry>, bool)> {
235 let ref_tip = notes_ref_tip(repo, notes_ref)?;
236 let tip = match ref_tip {
237 Some(oid) => oid,
238 None => match resolve_revision(repo, notes_ref) {
239 Ok(oid) => oid,
240 Err(_) => return Ok((Vec::new(), false)),
241 },
242 };
243 let obj = repo.odb().read(&tip)?;
244 let tree_oid = match obj.kind {
245 ObjectKind::Commit => parse_commit(&obj.data)?.tree,
246 ObjectKind::Tree => tip,
247 found => {
248 return Err(Error::NotesRefNotCommit {
249 notes_ref: notes_ref.to_owned(),
250 found,
251 })
252 }
253 };
254 let mut entries = Vec::new();
255 let mut listable_seen = 0usize;
256 let mut truncated = false;
257 collect_notes_tree_entries(
258 repo,
259 &tree_oid,
260 b"",
261 &mut entries,
262 max_entries,
263 &mut listable_seen,
264 &mut truncated,
265 )?;
266 Ok((entries, truncated))
267}
268
269fn notes_fanout(entries: &[NotesTreeEntry]) -> usize {
270 let mut note_count = entries
271 .iter()
272 .filter(|entry| note_object_name(&entry.path).is_some())
273 .count();
274 let mut fanout = 0usize;
275 while note_count > 0xff {
276 note_count >>= 8;
277 fanout += 1;
278 }
279 fanout
280}
281
282fn path_with_fanout(hex: &str, fanout: usize) -> Vec<u8> {
283 let mut path = Vec::with_capacity(hex.len() + fanout);
284 let bytes = hex.as_bytes();
285 let split = fanout.min(bytes.len() / 2);
286 for idx in 0..split {
287 let start = idx * 2;
288 path.extend_from_slice(&bytes[start..start + 2]);
289 path.push(b'/');
290 }
291 path.extend_from_slice(&bytes[split * 2..]);
292 path
293}
294
295fn write_notes_subtree(repo: &Repository, entries: &[NotesTreeEntry]) -> Result<ObjectId> {
296 let mut children: BTreeMap<Vec<u8>, NotesTreeChild> = BTreeMap::new();
297
298 for entry in entries {
299 if let Some(slash_pos) = entry.path.iter().position(|byte| *byte == b'/') {
300 let child_name = entry.path[..slash_pos].to_vec();
301 let child_entry = NotesTreeEntry {
302 mode: entry.mode,
303 path: entry.path[slash_pos + 1..].to_vec(),
304 oid: entry.oid,
305 };
306 children
307 .entry(child_name)
308 .or_insert_with(|| NotesTreeChild::Tree(Vec::new()));
309 if let Some(NotesTreeChild::Tree(tree_entries)) =
310 children.get_mut(&entry.path[..slash_pos])
311 {
312 tree_entries.push(child_entry);
313 }
314 } else {
315 children.insert(
316 entry.path.clone(),
317 NotesTreeChild::Blob {
318 mode: entry.mode,
319 oid: entry.oid,
320 },
321 );
322 }
323 }
324
325 let mut tree_entries = Vec::with_capacity(children.len());
326 for (name, child) in children {
327 match child {
328 NotesTreeChild::Blob { mode, oid } => tree_entries.push(TreeEntry { mode, name, oid }),
329 NotesTreeChild::Tree(child_entries) => {
330 let oid = write_notes_subtree(repo, &child_entries)?;
331 tree_entries.push(TreeEntry {
332 mode: 0o040000,
333 name,
334 oid,
335 });
336 }
337 }
338 }
339
340 tree_entries
341 .sort_by(|a, b| tree_entry_cmp(&a.name, a.mode == 0o040000, &b.name, b.mode == 0o040000));
342
343 let tree_data = serialize_tree(&tree_entries);
344 repo.odb().write(ObjectKind::Tree, &tree_data)
345}
346
347fn write_notes_tree(repo: &Repository, entries: &[NotesTreeEntry]) -> Result<ObjectId> {
349 let fanout = notes_fanout(entries);
350 let rewritten_entries: Vec<_> = entries
351 .iter()
352 .map(|entry| NotesTreeEntry {
353 mode: entry.mode,
354 path: note_object_name(&entry.path)
355 .map(|name| path_with_fanout(&name, fanout))
356 .unwrap_or_else(|| entry.path.clone()),
357 oid: entry.oid,
358 })
359 .collect();
360 write_notes_subtree(repo, &rewritten_entries)
361}
362
363pub(crate) struct NotesCommit<'a> {
365 pub(crate) message: &'a str,
366 pub(crate) author: &'a str,
368 pub(crate) committer: &'a str,
370}
371
372pub(crate) fn preview_notes_commit(
374 repo: &Repository,
375 parent: Option<ObjectId>,
376 entries: &[NotesTreeEntry],
377 commit: &NotesCommit<'_>,
378) -> Result<ObjectId> {
379 write_notes_commit_object(repo, entries, commit, parent.into_iter().collect())
380}
381
382fn write_notes_commit_object(
383 repo: &Repository,
384 entries: &[NotesTreeEntry],
385 commit: &NotesCommit<'_>,
386 parents: Vec<ObjectId>,
387) -> Result<ObjectId> {
388 let tree = write_notes_tree(repo, entries)?;
389 let message = if commit.message.ends_with('\n') {
390 commit.message.to_owned()
391 } else {
392 format!("{}\n", commit.message)
393 };
394 let data = CommitData {
395 tree,
396 parents,
397 author: commit.author.to_owned(),
398 committer: commit.committer.to_owned(),
399 author_raw: Vec::new(),
400 committer_raw: Vec::new(),
401 encoding: None,
402 message,
403 raw_message: None,
404 extra_headers: Vec::new(),
405 };
406 repo.odb()
407 .write(ObjectKind::Commit, &serialize_commit(&data))
408}
409
410fn signature_time(ident: &str) -> OffsetDateTime {
412 parse_signature_times(ident)
413 .and_then(|times| {
414 let offset =
415 UtcOffset::from_whole_seconds(i32::try_from(times.tz_offset_secs).ok()?).ok()?;
416 let at = OffsetDateTime::from_unix_timestamp(times.unix_seconds).ok()?;
417 Some(at.to_offset(offset))
418 })
419 .unwrap_or(OffsetDateTime::UNIX_EPOCH)
420}
421
422fn update_notes_ref(
425 repo: &Repository,
426 notes_ref: &str,
427 old: Option<ObjectId>,
428 new: ObjectId,
429 commit: &NotesCommit<'_>,
430) -> Result<()> {
431 let config = repo.config()?;
432 let log = should_autocreate_reflog_with_config(config.as_ref(), repo.git_dir(), notes_ref)
433 || repo.refs().reflog_exists(notes_ref)?;
434 let reflog = log.then(|| ReflogUpdate {
435 identity: commit.committer.to_owned(),
436 message: format!("notes: {}", commit.message.trim_end_matches('\n')),
437 time: signature_time(commit.committer),
438 old_oid: None,
439 new_oid: None,
440 });
441 store_write_value_on_store(
442 repo.refs(),
443 repo.git_dir(),
444 notes_ref,
445 Some(RawRef::Direct(new)),
446 old.map_or(Expected::Missing, Expected::Oid),
447 reflog,
448 )
449}
450
451fn commit_notes_tree(
456 repo: &Repository,
457 notes_ref: &str,
458 parent: Option<ObjectId>,
459 entries: &[NotesTreeEntry],
460 commit: &NotesCommit<'_>,
461) -> Result<ObjectId> {
462 let oid = write_notes_commit_object(repo, entries, commit, parent.into_iter().collect())?;
463 update_notes_ref(repo, notes_ref, parent, oid, commit)?;
464 Ok(oid)
465}
466
467pub fn write_notes_commit(
484 repo: &Repository,
485 notes_ref: &str,
486 entries: &[NotesTreeEntry],
487 message: &str,
488) -> Result<()> {
489 let (author, committer) = wall_clock_notes_idents(repo)?;
490 let parent = notes_ref_tip(repo, notes_ref)?;
491 commit_notes_tree(
492 repo,
493 notes_ref,
494 parent,
495 entries,
496 &NotesCommit {
497 message,
498 author: &author,
499 committer: &committer,
500 },
501 )?;
502 Ok(())
503}
504
505pub(crate) fn write_notes_commit_idents(
510 repo: &Repository,
511 notes_ref: &str,
512 parent: Option<ObjectId>,
513 entries: &[NotesTreeEntry],
514 message: &str,
515 author: &str,
516 committer: &str,
517) -> Result<()> {
518 commit_notes_tree(
519 repo,
520 notes_ref,
521 parent,
522 entries,
523 &NotesCommit {
524 message,
525 author,
526 committer,
527 },
528 )?;
529 Ok(())
530}
531
532#[must_use]
534pub fn remove_note_entry(entries: &mut Vec<NotesTreeEntry>, object_hex: &str) -> bool {
535 let before = entries.len();
536 entries.retain(|e| note_object_name(&e.path).as_deref() != Some(object_hex));
537 entries.len() < before
538}
539
540fn wall_clock_notes_idents(repo: &Repository) -> Result<(String, String)> {
542 let config = repo.config()?;
543 let now = repo.identity_now();
544 Ok((
545 notes_ident(repo.environment(), config.as_ref(), IdentRole::Author, now),
546 notes_ident(
547 repo.environment(),
548 config.as_ref(),
549 IdentRole::Committer,
550 now,
551 ),
552 ))
553}
554
555fn notes_ident<E: IdentityEnv>(
558 env: &E,
559 config: &ConfigSet,
560 role: IdentRole,
561 now: OffsetDateTime,
562) -> String {
563 let (name_key, email_key, date_key) = match role {
564 IdentRole::Author => ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_AUTHOR_DATE"),
565 IdentRole::Committer => (
566 "GIT_COMMITTER_NAME",
567 "GIT_COMMITTER_EMAIL",
568 "GIT_COMMITTER_DATE",
569 ),
570 };
571 let non_blank = |key: &str| env.var(key).filter(|v| !v.trim().is_empty());
572 let author_fallback = |key: &str| match role {
573 IdentRole::Committer => non_blank(key),
574 IdentRole::Author => None,
575 };
576
577 let name = non_blank(name_key)
578 .or_else(|| author_fallback("GIT_AUTHOR_NAME"))
579 .or_else(|| config.get("user.name"))
580 .unwrap_or_else(|| "Unknown".to_owned());
581 let email = non_blank(email_key)
582 .or_else(|| author_fallback("GIT_AUTHOR_EMAIL"))
583 .or_else(|| config.get("user.email"))
584 .unwrap_or_default();
585 assemble_identity(&name, &email, non_blank(date_key).as_deref(), now)
586}
587
588#[derive(Clone, Copy, Debug, PartialEq, Eq)]
590pub enum NotesMergeStrategy {
591 Manual,
593 Ours,
595 Theirs,
597 Union,
599 CatSortUniq,
601}
602
603#[derive(Clone, Debug)]
604enum LocalNoteState {
605 Unset,
606 Deleted,
607 Present(ObjectId),
608}
609
610#[derive(Clone, Debug)]
611struct NotesMergePair {
612 obj: ObjectId,
613 base_blob: Option<ObjectId>,
614 remote_blob: Option<ObjectId>,
615 local: LocalNoteState,
616}
617
618impl NotesMergePair {
619 fn local_change(&self) -> Option<Option<ObjectId>> {
622 match self.local {
623 LocalNoteState::Unset => None,
624 LocalNoteState::Present(blob) if Some(blob) == self.base_blob => None,
625 LocalNoteState::Present(blob) => Some(Some(blob)),
626 LocalNoteState::Deleted => Some(None),
627 }
628 }
629}
630
631#[derive(Debug, Clone, PartialEq, Eq, Hash)]
633pub struct NotesRef(String);
634
635impl NotesRef {
636 #[must_use]
638 pub fn commits() -> Self {
639 Self("refs/notes/commits".to_owned())
640 }
641
642 pub fn from_full(full: &str) -> Result<Self> {
653 if !full.starts_with("refs/notes/") {
654 return Err(Error::InvalidNotesRef(full.to_owned()));
655 }
656 use crate::check_ref_format::{check_refname_format, RefNameOptions};
657 if check_refname_format(full, &RefNameOptions::default()).is_err() {
658 return Err(Error::InvalidNotesRef(full.to_owned()));
659 }
660 Ok(Self(full.to_owned()))
661 }
662
663 #[must_use]
666 pub fn parse(short_or_full: &str) -> Self {
667 Self(expand_notes_ref(short_or_full))
668 }
669
670 #[must_use]
672 pub fn as_str(&self) -> &str {
673 &self.0
674 }
675}
676
677impl std::fmt::Display for NotesRef {
678 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
679 f.write_str(&self.0)
680 }
681}
682
683pub fn resolve_configured_notes_ref<E: IdentityEnv>(
694 env: &E,
695 config: &ConfigSet,
696) -> Result<NotesRef> {
697 if let Some(value) = env.var("GIT_NOTES_REF") {
698 return NotesRef::from_full(&value);
699 }
700 if let Some(value) = config.get("core.notesRef") {
701 return NotesRef::from_full(&value);
702 }
703 Ok(NotesRef::commits())
704}
705
706fn expand_notes_ref(short_or_full: &str) -> String {
708 if short_or_full.starts_with("refs/notes/") {
709 short_or_full.to_owned()
710 } else if short_or_full.starts_with("notes/") {
711 format!("refs/{short_or_full}")
712 } else {
713 format!("refs/notes/{short_or_full}")
714 }
715}
716
717fn notes_merge_worktree_path(repo: &Repository) -> std::path::PathBuf {
719 repo.git_dir().join(NOTES_MERGE_WORKTREE)
720}
721
722fn notes_merge_worktree_nonempty(worktree: &std::path::Path) -> bool {
724 if !worktree.is_dir() {
725 return false;
726 }
727 let Ok(entries) = fs::read_dir(worktree) else {
728 return false;
729 };
730 entries.flatten().next().is_some()
731}
732
733fn notes_tree_blob_by_object(
735 repo: &Repository,
736 tree_oid: &ObjectId,
737) -> Result<HashMap<ObjectId, ObjectId>> {
738 let mut flat = Vec::new();
739 let mut listable_seen = 0usize;
740 let mut truncated = false;
741 collect_notes_tree_entries(
742 repo,
743 tree_oid,
744 b"",
745 &mut flat,
746 None,
747 &mut listable_seen,
748 &mut truncated,
749 )?;
750 debug_assert!(!truncated);
751 let mut map = HashMap::new();
752 for entry in flat {
753 let Some(hex) = note_object_name(&entry.path) else {
754 continue;
755 };
756 map.insert(ObjectId::from_hex(&hex)?, entry.oid);
757 }
758 Ok(map)
759}
760
761fn diff_note_blob_changes(
762 repo: &Repository,
763 old_tree: Option<&ObjectId>,
764 new_tree: Option<&ObjectId>,
765) -> Result<Vec<NoteBlobChange>> {
766 let old_map = match old_tree {
767 Some(t) => notes_tree_blob_by_object(repo, t)?,
768 None => HashMap::new(),
769 };
770 let new_map = match new_tree {
771 Some(t) => notes_tree_blob_by_object(repo, t)?,
772 None => HashMap::new(),
773 };
774 let keys: BTreeSet<ObjectId> = old_map.keys().chain(new_map.keys()).copied().collect();
775 let mut out = Vec::new();
776 for obj in keys {
777 let o_old = old_map.get(&obj).copied();
778 let o_new = new_map.get(&obj).copied();
779 match (o_old, o_new) {
780 (None, Some(new_b)) => out.push((obj, None, Some(new_b))),
781 (Some(old_b), None) => out.push((obj, Some(old_b), None)),
782 (Some(old_b), Some(new_b)) if old_b != new_b => {
783 out.push((obj, Some(old_b), Some(new_b)));
784 }
785 _ => {}
786 }
787 }
788 Ok(out)
789}
790
791fn build_merge_pairs(
792 base_tree: Option<ObjectId>,
793 local_tree: ObjectId,
794 remote_tree: ObjectId,
795 repo: &Repository,
796) -> Result<Vec<NotesMergePair>> {
797 let remote_raw = diff_note_blob_changes(repo, base_tree.as_ref(), Some(&remote_tree))?;
798 let local_raw = diff_note_blob_changes(repo, base_tree.as_ref(), Some(&local_tree))?;
799 let mut map: HashMap<ObjectId, NotesMergePair> = HashMap::new();
800 for (obj, old_b, new_b) in remote_raw {
801 map.insert(
802 obj,
803 NotesMergePair {
804 obj,
805 base_blob: old_b,
806 remote_blob: new_b,
807 local: LocalNoteState::Unset,
808 },
809 );
810 }
811
812 for (obj, old_b, new_b) in local_raw {
813 let local_state = match new_b {
814 Some(new_oid) => LocalNoteState::Present(new_oid),
815 None => LocalNoteState::Deleted,
816 };
817 if let Some(p) = map.get_mut(&obj) {
818 p.local = local_state;
819 } else {
820 map.insert(
821 obj,
822 NotesMergePair {
823 obj,
824 base_blob: old_b,
825 remote_blob: old_b,
826 local: local_state,
827 },
828 );
829 }
830 }
831
832 let mut v: Vec<_> = map.into_values().collect();
833 v.sort_by_key(|a| a.obj);
834 Ok(v)
835}
836
837pub(crate) fn read_blob_bytes(repo: &Repository, oid: &ObjectId) -> Result<Vec<u8>> {
843 let obj = repo.odb().read(oid)?;
844 if obj.kind != ObjectKind::Blob {
845 return Err(Error::NotesObjectKind {
846 oid: *oid,
847 expected: ObjectKind::Blob,
848 found: obj.kind,
849 });
850 }
851 Ok(obj.data)
852}
853
854fn combine_notes_concatenate(
856 repo: &Repository,
857 cur: Option<&ObjectId>,
858 new_oid: Option<&ObjectId>,
859) -> Result<ObjectId> {
860 let new_data = match new_oid {
861 Some(n) => {
862 let obj = repo.odb().read(n)?;
863 if obj.kind != ObjectKind::Blob || obj.data.is_empty() {
864 Vec::new()
865 } else {
866 obj.data
867 }
868 }
869 None => Vec::new(),
870 };
871 if new_data.is_empty() {
872 let Some(c) = cur else {
873 return Err(Error::Message(
874 "combine_notes_concatenate: empty new and no current".into(),
875 ));
876 };
877 return Ok(*c);
878 }
879
880 let cur_data = match cur {
881 Some(c) => {
882 let obj = repo.odb().read(c)?;
883 if obj.kind != ObjectKind::Blob || obj.data.is_empty() {
884 Vec::new()
885 } else {
886 obj.data
887 }
888 }
889 None => Vec::new(),
890 };
891
892 if cur_data.is_empty() {
893 return repo.odb().write(ObjectKind::Blob, &new_data);
894 }
895
896 let mut cur_len = cur_data.len();
897 if cur_len > 0 && cur_data[cur_len - 1] == b'\n' {
898 cur_len -= 1;
899 }
900 let mut buf = Vec::with_capacity(cur_len + 2 + new_data.len());
901 buf.extend_from_slice(&cur_data[..cur_len]);
902 buf.push(b'\n');
903 buf.push(b'\n');
904 buf.extend_from_slice(&new_data);
905 repo.odb().write(ObjectKind::Blob, &buf)
906}
907
908fn note_blob_lines(data: &[u8]) -> Vec<String> {
909 if data.is_empty() {
910 return Vec::new();
911 }
912 let s = String::from_utf8_lossy(data);
913 s.split('\n').map(|l| l.to_owned()).collect()
914}
915
916fn combine_notes_cat_sort_uniq(
918 repo: &Repository,
919 cur: Option<&ObjectId>,
920 new_oid: Option<&ObjectId>,
921) -> Result<ObjectId> {
922 let mut lines: Vec<String> = Vec::new();
923 for oid in [cur, new_oid].into_iter().flatten() {
924 let obj = repo.odb().read(oid)?;
925 if obj.kind == ObjectKind::Blob && !obj.data.is_empty() {
926 lines.extend(note_blob_lines(&obj.data));
927 }
928 }
929 lines.retain(|l| !l.is_empty());
930 lines.sort();
931 lines.dedup();
932 let mut buf = String::new();
933 for l in &lines {
934 buf.push_str(l);
935 buf.push('\n');
936 }
937 repo.odb().write(ObjectKind::Blob, buf.as_bytes())
938}
939
940fn blob_to_lines(data: &[u8]) -> Vec<String> {
941 if data.is_empty() {
942 return vec![String::new()];
943 }
944 let s = String::from_utf8_lossy(data).into_owned();
945 s.split_inclusive('\n').map(|l| l.to_owned()).collect()
946}
947
948fn merge_note_blobs_conflict_markers(
949 repo: &Repository,
950 base: Option<&ObjectId>,
951 local: &ObjectId,
952 remote: &ObjectId,
953 local_ref: &str,
954 remote_ref: &str,
955) -> Result<Vec<u8>> {
956 let base_lines: Vec<String> = match base {
957 Some(b) => blob_to_lines(&read_blob_bytes(repo, b)?),
958 None => vec![String::new()],
959 };
960 let local_lines = blob_to_lines(&read_blob_bytes(repo, local)?);
961 let remote_lines = blob_to_lines(&read_blob_bytes(repo, remote)?);
962
963 let base_refs: Vec<&str> = base_lines.iter().map(|s| s.as_str()).collect();
964 let local_refs: Vec<&str> = local_lines.iter().map(|s| s.as_str()).collect();
965 let remote_refs: Vec<&str> = remote_lines.iter().map(|s| s.as_str()).collect();
966 let m3 = Merge3::new(&base_refs, &local_refs, &remote_refs);
967 let markers = StandardMarkers::new(Some(local_ref), Some(remote_ref));
968 let merged: String = m3
969 .merge_lines(false, &markers)
970 .into_iter()
971 .map(|cow| cow.into_owned())
972 .collect();
973 Ok(merged.into_bytes())
974}
975
976struct NoteConflict {
978 base: Option<ObjectId>,
979 local: Option<ObjectId>,
980 remote: Option<ObjectId>,
981}
982
983fn remove_entry(entries: &mut Vec<NotesTreeEntry>, obj_hex: &str) {
984 entries.retain(|e| note_object_name(&e.path).as_deref() != Some(obj_hex));
985}
986
987#[expect(clippy::too_many_arguments)]
989fn merge_one_note_change(
990 repo: &Repository,
991 obj: &ObjectId,
992 sides: &NoteConflict,
993 strategy: NotesMergeStrategy,
994 local_ref: &str,
995 remote_ref: &str,
996 worktree: &std::path::Path,
997 commit_msg: &mut String,
998 entries: &mut Vec<NotesTreeEntry>,
999 has_worktree: &mut bool,
1000) -> Result<bool> {
1001 let obj_hex = obj.to_hex();
1002 let combine = match strategy {
1003 NotesMergeStrategy::Ours => return Ok(false),
1004 NotesMergeStrategy::Theirs => {
1005 match sides.remote {
1006 Some(r) => upsert_note_entry(entries, &obj_hex, r),
1007 None => remove_entry(entries, &obj_hex),
1008 }
1009 return Ok(false);
1010 }
1011 NotesMergeStrategy::Union => combine_notes_concatenate,
1012 NotesMergeStrategy::CatSortUniq => combine_notes_cat_sort_uniq,
1013 NotesMergeStrategy::Manual => {
1014 let data = match (&sides.local, &sides.remote) {
1015 (None, None) => {
1016 remove_entry(entries, &obj_hex);
1017 return Ok(false);
1018 }
1019 (None, Some(r)) => read_blob_bytes(repo, r)?,
1020 (Some(l), None) => read_blob_bytes(repo, l)?,
1021 (Some(l), Some(r)) => merge_note_blobs_conflict_markers(
1022 repo,
1023 sides.base.as_ref(),
1024 l,
1025 r,
1026 local_ref,
1027 remote_ref,
1028 )?,
1029 };
1030 if !*has_worktree {
1031 if notes_merge_worktree_nonempty(worktree) {
1032 return Err(Error::NotesMergeInProgress {
1033 path: worktree.to_path_buf(),
1034 });
1035 }
1036 fs::create_dir_all(worktree)?;
1037 *has_worktree = true;
1038 }
1039 if !commit_msg.contains("Conflicts:") {
1040 commit_msg.push_str("\n\nConflicts:\n");
1041 }
1042 commit_msg.push_str(&format!("\t{obj_hex}\n"));
1043 fs::write(worktree.join(&obj_hex), data)?;
1044 remove_entry(entries, &obj_hex);
1045 return Ok(true);
1046 }
1047 };
1048 match (&sides.local, &sides.remote) {
1049 (None, None) => remove_entry(entries, &obj_hex),
1050 (Some(_), None) => {}
1051 (local, Some(r)) => {
1052 let out = combine(repo, local.as_ref(), Some(r))?;
1053 upsert_note_entry(entries, &obj_hex, out);
1054 }
1055 }
1056 Ok(false)
1057}
1058
1059pub fn upsert_note_entry(entries: &mut Vec<NotesTreeEntry>, hex: &str, blob: ObjectId) {
1061 remove_entry(entries, hex);
1062 entries.push(NotesTreeEntry {
1063 mode: 0o100644,
1064 path: hex.as_bytes().to_vec(),
1065 oid: blob,
1066 });
1067}
1068
1069fn remote_unchanged(base: Option<ObjectId>, remote: Option<ObjectId>) -> bool {
1070 match (base, remote) {
1071 (Some(b), Some(r)) => b == r,
1072 (None, None) => true,
1073 _ => false,
1074 }
1075}
1076
1077fn same_change_local_remote(p: &NotesMergePair) -> bool {
1078 match (&p.local, p.remote_blob) {
1079 (LocalNoteState::Present(l), Some(r)) => l == &r,
1080 (LocalNoteState::Deleted, None) => true,
1081 (LocalNoteState::Unset, Some(r)) => Some(r) == p.base_blob,
1082 (LocalNoteState::Unset, None) => p.base_blob.is_none(),
1083 _ => false,
1084 }
1085}
1086
1087fn adopt_remote_note(entries: &mut Vec<NotesTreeEntry>, obj_hex: &str, remote: Option<ObjectId>) {
1088 match remote {
1089 Some(oid) => upsert_note_entry(entries, obj_hex, oid),
1090 None => remove_entry(entries, obj_hex),
1091 }
1092}
1093
1094#[expect(clippy::too_many_arguments)]
1095fn merge_changes_into_entries(
1096 repo: &Repository,
1097 pairs: &[NotesMergePair],
1098 strategy: NotesMergeStrategy,
1099 local_ref: &str,
1100 remote_ref: &str,
1101 worktree: &std::path::Path,
1102 commit_msg: &mut String,
1103 entries: &mut Vec<NotesTreeEntry>,
1104) -> Result<usize> {
1105 let mut conflicts = 0usize;
1106 let mut has_worktree = false;
1107 for p in pairs {
1108 if remote_unchanged(p.base_blob, p.remote_blob) {
1109 continue;
1110 }
1111 if same_change_local_remote(p) {
1112 continue;
1113 }
1114 let Some(local) = p.local_change() else {
1115 adopt_remote_note(entries, &p.obj.to_hex(), p.remote_blob);
1116 continue;
1117 };
1118 let sides = NoteConflict {
1119 base: p.base_blob,
1120 local,
1121 remote: p.remote_blob,
1122 };
1123 if merge_one_note_change(
1124 repo,
1125 &p.obj,
1126 &sides,
1127 strategy,
1128 local_ref,
1129 remote_ref,
1130 worktree,
1131 commit_msg,
1132 entries,
1133 &mut has_worktree,
1134 )? {
1135 conflicts += 1;
1136 }
1137 }
1138 Ok(conflicts)
1139}
1140
1141fn resolve_commit_tree(repo: &Repository, commit_oid: &ObjectId) -> Result<ObjectId> {
1142 let obj = repo.odb().read(commit_oid)?;
1143 if obj.kind != ObjectKind::Commit {
1144 return Err(Error::NotesObjectKind {
1145 oid: *commit_oid,
1146 expected: ObjectKind::Commit,
1147 found: obj.kind,
1148 });
1149 }
1150 Ok(parse_commit(&obj.data)?.tree)
1151}
1152
1153fn resolve_notes_commit_optional(repo: &Repository, notes_ref: &str) -> Result<Option<ObjectId>> {
1154 let Some(oid) = notes_ref_tip(repo, notes_ref)? else {
1155 return Ok(None);
1156 };
1157 let kind = repo.odb().read(&oid)?.kind;
1158 if kind != ObjectKind::Commit {
1159 return Err(Error::NotesRefNotCommit {
1160 notes_ref: notes_ref.to_owned(),
1161 found: kind,
1162 });
1163 }
1164 Ok(Some(oid))
1165}
1166
1167#[derive(Clone, Copy)]
1169enum NotesRefUpdate {
1170 Leave,
1172 From(Option<ObjectId>),
1174}
1175
1176fn write_notes_commit_with_parents(
1178 repo: &Repository,
1179 notes_ref: &str,
1180 entries: &[NotesTreeEntry],
1181 message: &str,
1182 parents: &[ObjectId],
1183 update: NotesRefUpdate,
1184) -> Result<ObjectId> {
1185 let (author, committer) = wall_clock_notes_idents(repo)?;
1186 let commit = NotesCommit {
1187 message,
1188 author: &author,
1189 committer: &committer,
1190 };
1191 let oid = write_notes_commit_object(repo, entries, &commit, parents.to_vec())?;
1192 if let NotesRefUpdate::From(old) = update {
1193 update_notes_ref(repo, notes_ref, old, oid, &commit)?;
1194 }
1195 Ok(oid)
1196}
1197
1198pub fn notes_merge_inner(
1208 repo: &Repository,
1209 local_ref: &str,
1210 remote_ref: &str,
1211 strategy: NotesMergeStrategy,
1212) -> Result<std::result::Result<ObjectId, ObjectId>> {
1213 let local_commit = resolve_notes_commit_optional(repo, local_ref)?;
1214 let remote_commit = resolve_notes_commit_optional(repo, remote_ref)?;
1215 match (local_commit, remote_commit) {
1216 (None, None) => Err(Error::NotesMergeBothEmpty {
1217 local: local_ref.to_owned(),
1218 remote: remote_ref.to_owned(),
1219 }),
1220 (None, Some(r)) => Ok(Ok(r)),
1221 (Some(l), None) => Ok(Ok(l)),
1222 (Some(local_oid), Some(remote_oid)) => {
1223 if local_oid == remote_oid {
1224 return Ok(Ok(local_oid));
1225 }
1226 let bases = merge_bases_first_vs_rest(repo, local_oid, &[remote_oid])?;
1227 let base_commit = bases.into_iter().next();
1228 if Some(local_oid) == base_commit {
1229 return Ok(Ok(remote_oid));
1230 }
1231 if Some(remote_oid) == base_commit {
1232 return Ok(Ok(local_oid));
1233 }
1234 let base_tree = base_commit
1235 .map(|bc| resolve_commit_tree(repo, &bc))
1236 .transpose()?;
1237 let local_tree = resolve_commit_tree(repo, &local_oid)?;
1238 let remote_tree = resolve_commit_tree(repo, &remote_oid)?;
1239 let mut commit_msg = format!("Merged notes from {remote_ref} into {local_ref}\n\n");
1240 let pairs = build_merge_pairs(base_tree, local_tree, remote_tree, repo)?;
1241 let mut entries = Vec::new();
1242 let mut listable_seen = 0usize;
1243 let mut truncated = false;
1244 collect_notes_tree_entries(
1245 repo,
1246 &local_tree,
1247 b"",
1248 &mut entries,
1249 None,
1250 &mut listable_seen,
1251 &mut truncated,
1252 )?;
1253 debug_assert!(!truncated);
1254 let worktree = notes_merge_worktree_path(repo);
1255 let conflicts = merge_changes_into_entries(
1256 repo,
1257 &pairs,
1258 strategy,
1259 local_ref,
1260 remote_ref,
1261 &worktree,
1262 &mut commit_msg,
1263 &mut entries,
1264 )?;
1265 let merge_parents = vec![local_oid, remote_oid];
1266 if conflicts > 0 {
1267 let partial = write_notes_commit_with_parents(
1268 repo,
1269 local_ref,
1270 &entries,
1271 &commit_msg,
1272 &merge_parents,
1273 NotesRefUpdate::Leave,
1274 )?;
1275 return Ok(Err(partial));
1276 }
1277 let new_oid = write_notes_commit_with_parents(
1278 repo,
1279 local_ref,
1280 &entries,
1281 &commit_msg,
1282 &merge_parents,
1283 NotesRefUpdate::From(Some(local_oid)),
1284 )?;
1285 Ok(Ok(new_oid))
1286 }
1287 }
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292 use super::*;
1293 use crate::environment::RepositoryOptions;
1294 use crate::objects::ObjectKind;
1295 use crate::repo::Repository;
1296 use std::process::Command;
1297 use tempfile::TempDir;
1298
1299 fn git(dir: &std::path::Path, args: &[&str]) {
1300 let out = Command::new("git")
1301 .current_dir(dir)
1302 .args(args)
1303 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1304 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1305 .env("GIT_AUTHOR_NAME", "Test")
1306 .env("GIT_AUTHOR_EMAIL", "t@example.com")
1307 .env("GIT_COMMITTER_NAME", "Test")
1308 .env("GIT_COMMITTER_EMAIL", "t@example.com")
1309 .output()
1310 .expect("git");
1311 assert!(
1312 out.status.success(),
1313 "git {:?}: {}",
1314 args,
1315 String::from_utf8_lossy(&out.stderr)
1316 );
1317 }
1318
1319 fn open_repo(dir: &std::path::Path) -> Repository {
1320 git(dir, &["init", "-q", "-b", "main", "."]);
1321 std::fs::write(dir.join("f"), b"x\n").unwrap();
1322 git(dir, &["add", "f"]);
1323 git(dir, &["commit", "-qm", "init"]);
1324 Repository::open(
1325 &dir.join(".git"),
1326 &RepositoryOptions::empty().with_work_tree(dir),
1327 )
1328 .expect("open")
1329 }
1330
1331 #[test]
1332 fn note_object_name_parses_flat_and_fanout_paths() {
1333 let full = "0123456789abcdef0123456789abcdef01234567";
1334 assert_eq!(note_object_name(full.as_bytes()), Some(full.to_owned()));
1335 assert_eq!(
1336 note_object_name(b"01/23/456789abcdef0123456789abcdef01234567"),
1337 Some(full.to_owned())
1338 );
1339 assert!(note_object_name(b"not-a-note").is_none());
1340 }
1341
1342 #[test]
1343 fn from_full_rejects_whitespace_padded_ref() {
1344 let err = NotesRef::from_full(" refs/notes/spaced ").expect_err("padded");
1345 assert!(matches!(err, Error::InvalidNotesRef(_)));
1346 let err = NotesRef::from_full("refs/notes/spaced ").expect_err("trailing");
1347 assert!(matches!(err, Error::InvalidNotesRef(_)));
1348 let ok = NotesRef::from_full("refs/notes/spaced").expect("valid");
1349 assert_eq!(ok.as_str(), "refs/notes/spaced");
1350 }
1351
1352 #[test]
1353 fn resolve_configured_notes_ref_rejects_empty_git_notes_ref() {
1354 use crate::config::ConfigSet;
1355 use std::collections::HashMap;
1356 use std::ffi::OsString;
1357
1358 struct MapEnv(HashMap<String, String>);
1359 impl crate::ident_resolve::IdentityEnv for MapEnv {
1360 fn var(&self, key: &str) -> Option<String> {
1361 self.0.get(key).cloned()
1362 }
1363 fn var_os(&self, key: &str) -> Option<OsString> {
1364 self.var(key).map(OsString::from)
1365 }
1366 }
1367
1368 let env = MapEnv(HashMap::from([("GIT_NOTES_REF".to_owned(), String::new())]));
1369 let err = resolve_configured_notes_ref(&env, &ConfigSet::default())
1370 .expect_err("empty GIT_NOTES_REF");
1371 assert!(matches!(err, Error::InvalidNotesRef(_)));
1372 }
1373
1374 #[test]
1375 fn expand_notes_ref_and_parse() {
1376 assert_eq!(expand_notes_ref("commits"), "refs/notes/commits");
1377 assert_eq!(expand_notes_ref("notes/foo"), "refs/notes/foo");
1378 assert_eq!(expand_notes_ref("refs/notes/bar"), "refs/notes/bar");
1379 assert_eq!(NotesRef::parse("review").as_str(), "refs/notes/review");
1380 }
1381
1382 #[test]
1383 fn notes_merge_worktree_nonempty_detects_files() {
1384 let tmp = TempDir::new().unwrap();
1385 let wt = tmp.path().join("wt");
1386 assert!(!notes_merge_worktree_nonempty(&wt));
1387 std::fs::create_dir_all(&wt).unwrap();
1388 assert!(!notes_merge_worktree_nonempty(&wt));
1389 std::fs::write(wt.join("x"), b"").unwrap();
1390 assert!(notes_merge_worktree_nonempty(&wt));
1391 }
1392
1393 #[test]
1394 fn combine_notes_helpers_and_upsert() {
1395 let tmp = TempDir::new().unwrap();
1396 let repo = open_repo(tmp.path());
1397 let a = repo.odb().write(ObjectKind::Blob, b"line1\n").unwrap();
1398 let b = repo.odb().write(ObjectKind::Blob, b"line2\n").unwrap();
1399 let cat = combine_notes_concatenate(&repo, Some(&a), Some(&b)).unwrap();
1400 let merged = repo.odb().read(&cat).unwrap();
1401 assert!(String::from_utf8_lossy(&merged.data).contains("line1"));
1402 let _uniq = combine_notes_cat_sort_uniq(&repo, Some(&a), Some(&b)).unwrap();
1403
1404 let hex = "0123456789abcdef0123456789abcdef01234567";
1405 let mut entries = Vec::new();
1406 upsert_note_entry(&mut entries, hex, a);
1407 upsert_note_entry(&mut entries, hex, b);
1408 assert_eq!(entries.len(), 1);
1409 assert_eq!(entries[0].oid, b);
1410 }
1411
1412 #[test]
1413 fn read_and_write_notes_roundtrip() {
1414 let tmp = TempDir::new().unwrap();
1415 let repo = open_repo(tmp.path());
1416 let head = Command::new("git")
1417 .current_dir(tmp.path())
1418 .args(["rev-parse", "HEAD"])
1419 .output()
1420 .unwrap();
1421 let commit = ObjectId::from_hex(std::str::from_utf8(&head.stdout).unwrap().trim()).unwrap();
1422 let blob = repo.odb().write(ObjectKind::Blob, b"hello\n").unwrap();
1423 upsert_note_entry(&mut vec![], &commit.to_hex(), blob);
1424 let entries = vec![NotesTreeEntry {
1425 mode: 0o100644,
1426 path: commit.to_hex().into_bytes(),
1427 oid: blob,
1428 }];
1429 write_notes_commit(&repo, "refs/notes/commits", &entries, "note").unwrap();
1430 let read_back = read_notes_tree(&repo, "refs/notes/commits").unwrap();
1431 assert_eq!(read_back.len(), 1);
1432 assert_eq!(read_back[0].oid, blob);
1433 }
1434
1435 #[test]
1436 fn notes_merge_ours_keeps_local_note() {
1437 let tmp = TempDir::new().unwrap();
1438 let repo = open_repo(tmp.path());
1439 let commit = ObjectId::from_hex(
1440 std::str::from_utf8(
1441 &Command::new("git")
1442 .current_dir(tmp.path())
1443 .args(["rev-parse", "HEAD"])
1444 .output()
1445 .unwrap()
1446 .stdout,
1447 )
1448 .unwrap()
1449 .trim(),
1450 )
1451 .unwrap();
1452 git(
1453 tmp.path(),
1454 &["notes", "add", "-m", "base", &commit.to_hex()],
1455 );
1456 let base = crate::refs::resolve_ref(repo.git_dir(), "refs/notes/commits").unwrap();
1457 git(
1458 tmp.path(),
1459 &["notes", "add", "-f", "-m", "local", &commit.to_hex()],
1460 );
1461 let local = crate::refs::resolve_ref(repo.git_dir(), "refs/notes/commits").unwrap();
1462 git(
1463 tmp.path(),
1464 &["update-ref", "refs/notes/remote", &base.to_hex()],
1465 );
1466 git(
1467 tmp.path(),
1468 &[
1469 "notes",
1470 "--ref=refs/notes/remote",
1471 "add",
1472 "-f",
1473 "-m",
1474 "remote",
1475 &commit.to_hex(),
1476 ],
1477 );
1478 git(
1479 tmp.path(),
1480 &["update-ref", "refs/notes/commits", &local.to_hex()],
1481 );
1482 notes_merge_inner(
1483 &repo,
1484 "refs/notes/commits",
1485 "refs/notes/remote",
1486 NotesMergeStrategy::Ours,
1487 )
1488 .expect("merge")
1489 .expect("ok");
1490 let show = Command::new("git")
1491 .current_dir(tmp.path())
1492 .args(["notes", "show", &commit.to_hex()])
1493 .output()
1494 .unwrap();
1495 assert_eq!(String::from_utf8_lossy(&show.stdout).trim(), "local");
1496 }
1497
1498 #[test]
1499 fn notes_merge_manual_leaves_conflict_commit() {
1500 let tmp = TempDir::new().unwrap();
1501 let repo = open_repo(tmp.path());
1502 let commit = ObjectId::from_hex(
1503 std::str::from_utf8(
1504 &Command::new("git")
1505 .current_dir(tmp.path())
1506 .args(["rev-parse", "HEAD"])
1507 .output()
1508 .unwrap()
1509 .stdout,
1510 )
1511 .unwrap()
1512 .trim(),
1513 )
1514 .unwrap();
1515 git(
1516 tmp.path(),
1517 &["notes", "add", "-m", "base", &commit.to_hex()],
1518 );
1519 let base = crate::refs::resolve_ref(repo.git_dir(), "refs/notes/commits").unwrap();
1520 git(
1521 tmp.path(),
1522 &["notes", "add", "-f", "-m", "local", &commit.to_hex()],
1523 );
1524 let local = crate::refs::resolve_ref(repo.git_dir(), "refs/notes/commits").unwrap();
1525 git(
1526 tmp.path(),
1527 &["update-ref", "refs/notes/remote", &base.to_hex()],
1528 );
1529 git(
1530 tmp.path(),
1531 &[
1532 "notes",
1533 "--ref=refs/notes/remote",
1534 "add",
1535 "-f",
1536 "-m",
1537 "remote",
1538 &commit.to_hex(),
1539 ],
1540 );
1541 git(
1542 tmp.path(),
1543 &["update-ref", "refs/notes/commits", &local.to_hex()],
1544 );
1545 let result = notes_merge_inner(
1546 &repo,
1547 "refs/notes/commits",
1548 "refs/notes/remote",
1549 NotesMergeStrategy::Manual,
1550 )
1551 .expect("merge");
1552 let partial = match result {
1553 Err(partial) => partial,
1554 Ok(_) => panic!("manual merge must stop with conflicts"),
1555 };
1556 assert_ne!(
1557 partial, local,
1558 "partial merge commit must differ from the pre-merge tip"
1559 );
1560 assert_eq!(
1561 crate::refs::resolve_ref(repo.git_dir(), "refs/notes/commits").unwrap(),
1562 local,
1563 "manual merge conflicts must leave the live notes ref at the local tip"
1564 );
1565 assert!(notes_merge_worktree_nonempty(¬es_merge_worktree_path(
1566 &repo
1567 )));
1568
1569 let err = notes_merge_inner(
1570 &repo,
1571 "refs/notes/commits",
1572 "refs/notes/remote",
1573 NotesMergeStrategy::Manual,
1574 )
1575 .expect_err("earlier conflicts are still unresolved");
1576 assert!(matches!(
1577 err,
1578 Error::NotesMergeInProgress { ref path } if *path == notes_merge_worktree_path(&repo)
1579 ));
1580 }
1581
1582 #[test]
1583 fn read_notes_tree_missing_ref_is_empty() {
1584 let tmp = TempDir::new().unwrap();
1585 let repo = open_repo(tmp.path());
1586 assert!(read_notes_tree(&repo, "refs/notes/commits")
1587 .unwrap()
1588 .is_empty());
1589 }
1590
1591 #[test]
1592 fn notes_merge_empty_remote_adopts_remote() {
1593 let tmp = TempDir::new().unwrap();
1594 let repo = open_repo(tmp.path());
1595 let local = write_notes_commit_with_parents(
1596 &repo,
1597 "refs/notes/commits",
1598 &[],
1599 "local only",
1600 &[],
1601 NotesRefUpdate::From(None),
1602 )
1603 .unwrap();
1604 let result = notes_merge_inner(
1605 &repo,
1606 "refs/notes/commits",
1607 "refs/notes/missing",
1608 NotesMergeStrategy::Theirs,
1609 )
1610 .expect("merge");
1611 assert_eq!(result.expect("ok"), local);
1612 }
1613
1614 #[test]
1615 fn notes_merge_of_two_empty_refs_is_typed() {
1616 let tmp = TempDir::new().unwrap();
1617 let repo = open_repo(tmp.path());
1618 let err = notes_merge_inner(
1619 &repo,
1620 "refs/notes/commits",
1621 "refs/notes/missing",
1622 NotesMergeStrategy::Union,
1623 )
1624 .expect_err("nothing to merge");
1625 assert!(matches!(
1626 err,
1627 Error::NotesMergeBothEmpty { ref local, ref remote }
1628 if local == "refs/notes/commits" && remote == "refs/notes/missing"
1629 ));
1630 }
1631
1632 #[test]
1633 fn read_notes_tree_rejects_blob_tip() {
1634 let tmp = TempDir::new().unwrap();
1635 let repo = open_repo(tmp.path());
1636 let blob = repo.odb().write(ObjectKind::Blob, b"not notes\n").unwrap();
1637 git(
1638 tmp.path(),
1639 &["update-ref", "refs/notes/blob", &blob.to_hex()],
1640 );
1641 let err = read_notes_tree(&repo, "refs/notes/blob").expect_err("blob tip");
1642 assert!(matches!(
1643 err,
1644 Error::NotesRefNotCommit { ref notes_ref, found: ObjectKind::Blob }
1645 if notes_ref == "refs/notes/blob"
1646 ));
1647 let err = notes_merge_inner(
1648 &repo,
1649 "refs/notes/commits",
1650 "refs/notes/blob",
1651 NotesMergeStrategy::Ours,
1652 )
1653 .expect_err("blob remote");
1654 assert!(matches!(
1655 err,
1656 Error::NotesRefNotCommit {
1657 found: ObjectKind::Blob,
1658 ..
1659 }
1660 ));
1661 }
1662
1663 #[test]
1664 fn write_notes_commit_goes_through_injected_ref_store() {
1665 let tmp = TempDir::new().unwrap();
1666 let repo = open_repo(tmp.path()).with_ref_store(std::sync::Arc::new(
1667 crate::refs::store::MemoryRefStore::new(),
1668 ));
1669 let blob = repo.odb().write(ObjectKind::Blob, b"in memory\n").unwrap();
1670 let mut entries = Vec::new();
1671 upsert_note_entry(&mut entries, &"ab".repeat(20), blob);
1672 write_notes_commit(&repo, "refs/notes/commits", &entries, "first").unwrap();
1673 let first = repo.refs().resolve("refs/notes/commits").unwrap();
1674 write_notes_commit(&repo, "refs/notes/commits", &[], "second").unwrap();
1675 let second = repo.refs().resolve("refs/notes/commits").unwrap();
1676
1677 let commit = parse_commit(&repo.odb().read(&second).unwrap().data).unwrap();
1678 assert_eq!(commit.parents, vec![first]);
1679 assert!(read_notes_tree(&repo, "refs/notes/commits")
1680 .unwrap()
1681 .is_empty());
1682 assert!(
1683 !tmp.path().join(".git/refs/notes/commits").exists(),
1684 "notes ref must live in the injected store, not on disk"
1685 );
1686 }
1687
1688 #[test]
1689 fn local_change_distinguishes_unchanged_deleted_and_set() {
1690 let base = ObjectId::from_hex(&"11".repeat(20)).unwrap();
1691 let other = ObjectId::from_hex(&"22".repeat(20)).unwrap();
1692 let pair = |local| NotesMergePair {
1693 obj: other,
1694 base_blob: Some(base),
1695 remote_blob: None,
1696 local,
1697 };
1698 assert_eq!(pair(LocalNoteState::Unset).local_change(), None);
1699 assert_eq!(pair(LocalNoteState::Present(base)).local_change(), None);
1700 assert_eq!(
1701 pair(LocalNoteState::Present(other)).local_change(),
1702 Some(Some(other))
1703 );
1704 assert_eq!(pair(LocalNoteState::Deleted).local_change(), Some(None));
1705 }
1706}