1#![allow(clippy::too_many_arguments)]
9
10use sley_config::GitConfig;
11use sley_core::{GitError, ObjectFormat, ObjectId, Result};
12use sley_object::{
13 BString, Commit, EncodedObject, ObjectType, Tree, TreeBuilder, TreeEntries, TreeEntry,
14 tree_entry_object_type,
15};
16use sley_odb::{FileObjectDatabase, ObjectReader, ObjectWriter};
17use sley_refs::{FileRefStore, RefTarget, RefUpdate, ReflogEntry};
18use sley_sequencer::{CommitCreate, create_commit};
19use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
20use std::path::Path;
21
22pub const DEFAULT_NOTES_REF: &str = "refs/notes/commits";
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct NotesRef(pub String);
28
29impl NotesRef {
30 pub fn expand(name: &str) -> Self {
33 Self(expand_notes_ref(name))
34 }
35
36 pub fn as_str(&self) -> &str {
38 &self.0
39 }
40}
41
42impl From<&str> for NotesRef {
43 fn from(value: &str) -> Self {
44 Self::expand(value)
45 }
46}
47
48impl From<String> for NotesRef {
49 fn from(value: String) -> Self {
50 Self::expand(&value)
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Note {
57 pub annotated: ObjectId,
58 pub blob: ObjectId,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct NotesCommitIdentity {
64 pub author: Vec<u8>,
65 pub committer: Vec<u8>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum UpsertNoteOutcome {
71 Updated { notes_commit: ObjectId },
73 Unchanged,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum RemoveNoteOutcome {
80 Removed { notes_commit: ObjectId },
82 Unchanged,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum NotesMergeStrategy {
89 Manual,
90 Ours,
91 Theirs,
92 Union,
93 CatSortUniq,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct NotesMergeConflict {
99 pub annotated: ObjectId,
100 pub base: Option<ObjectId>,
101 pub local: Option<ObjectId>,
102 pub remote: Option<ObjectId>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum NotesMergeOutcome {
108 AlreadyUpToDate { result: ObjectId },
110 FastForward { result: ObjectId },
112 Merged { result: ObjectId },
114 Conflicted {
116 partial: ObjectId,
117 conflicts: Vec<NotesMergeConflict>,
118 },
119}
120
121pub fn resolve_notes_ref(git_dir: &Path, ref_override: Option<&str>) -> Result<NotesRef> {
124 resolve_notes_ref_impl(git_dir, ref_override, None)
125}
126
127pub fn resolve_notes_ref_with_config(
137 git_dir: &Path,
138 ref_override: Option<&str>,
139 config: &GitConfig,
140) -> Result<NotesRef> {
141 resolve_notes_ref_impl(git_dir, ref_override, Some(config))
142}
143
144fn resolve_notes_ref_impl(
145 git_dir: &Path,
146 ref_override: Option<&str>,
147 config: Option<&GitConfig>,
148) -> Result<NotesRef> {
149 if let Some(value) = ref_override {
150 return Ok(NotesRef::expand(value));
151 }
152 if let Ok(value) = std::env::var("GIT_NOTES_REF")
153 && !value.is_empty()
154 {
155 return Ok(NotesRef::expand(&value));
156 }
157 let owned_config;
160 let config = match config {
161 Some(config) => Some(config),
162 None => match read_repo_config(git_dir) {
163 Ok(config) => {
164 owned_config = config;
165 Some(&owned_config)
166 }
167 Err(_) => None,
168 },
169 };
170 if let Some(config) = config
171 && let Some(value) = config.get("core", None, "notesRef")
172 && !value.is_empty()
173 {
174 return Ok(NotesRef::expand(value));
175 }
176 Ok(NotesRef::expand(DEFAULT_NOTES_REF))
177}
178
179pub struct NotesIter {
181 db: FileObjectDatabase,
182 format: ObjectFormat,
183 stack: Vec<(ObjectId, String)>,
184 pending: Vec<Note>,
185}
186
187impl NotesIter {
188 fn new(
189 git_dir: &Path,
190 format: ObjectFormat,
191 store: &FileRefStore,
192 notes_ref: &NotesRef,
193 ) -> Result<Self> {
194 let Some(tree_oid) = notes_tree_oid(git_dir, format, store, notes_ref)? else {
195 return Ok(Self {
196 db: FileObjectDatabase::from_git_dir(git_dir, format),
197 format,
198 stack: Vec::new(),
199 pending: Vec::new(),
200 });
201 };
202 Ok(Self {
203 db: FileObjectDatabase::from_git_dir(git_dir, format),
204 format,
205 stack: vec![(tree_oid, String::new())],
206 pending: Vec::new(),
207 })
208 }
209}
210
211impl Iterator for NotesIter {
212 type Item = Result<Note>;
213
214 fn next(&mut self) -> Option<Self::Item> {
215 loop {
216 if let Some(note) = self.pending.pop() {
217 return Some(Ok(note));
218 }
219 let (tree_oid, prefix) = self.stack.pop()?;
220 let entries = match load_hex_tree_entries(&self.db, self.format, &tree_oid) {
221 Ok(entries) => entries,
222 Err(err) => return Some(Err(err)),
223 };
224 for (name, mode, oid) in entries.into_iter().rev() {
225 if tree_entry_object_type(mode) == ObjectType::Tree {
226 let mut nested = prefix.clone();
227 nested.push_str(&name);
228 self.stack.push((oid, nested));
229 } else {
230 let mut hex = prefix.clone();
231 hex.push_str(&name);
232 if hex.len() != self.format.hex_len() {
233 continue;
234 }
235 let Ok(annotated) = ObjectId::from_hex(self.format, &hex) else {
236 continue;
237 };
238 self.pending.push(Note {
239 annotated,
240 blob: oid,
241 });
242 }
243 }
244 }
245 }
246}
247
248pub fn iter_notes(
250 git_dir: &Path,
251 format: ObjectFormat,
252 store: &FileRefStore,
253 notes_ref: &NotesRef,
254) -> Result<NotesIter> {
255 NotesIter::new(git_dir, format, store, notes_ref)
256}
257
258pub fn list_notes(
260 git_dir: &Path,
261 format: ObjectFormat,
262 store: &FileRefStore,
263 notes_ref: &NotesRef,
264) -> Result<Vec<Note>> {
265 let mut notes = iter_notes(git_dir, format, store, notes_ref)?.collect::<Result<Vec<_>>>()?;
266 notes.sort_by_key(|entry| entry.annotated.to_hex());
267 Ok(notes)
268}
269
270pub fn read_note_for(
272 git_dir: &Path,
273 format: ObjectFormat,
274 store: &FileRefStore,
275 notes_ref: &NotesRef,
276 annotated: &ObjectId,
277) -> Result<Option<ObjectId>> {
278 let Some(tree_oid) = notes_tree_oid(git_dir, format, store, notes_ref)? else {
279 return Ok(None);
280 };
281 let db = FileObjectDatabase::from_git_dir(git_dir, format);
282 lookup_note_for(&db, format, &tree_oid, "", &annotated.to_hex())
283}
284
285pub fn read_note_from_tree(
287 git_dir: &Path,
288 format: ObjectFormat,
289 tree_oid: &ObjectId,
290 annotated: &ObjectId,
291) -> Result<Option<ObjectId>> {
292 let db = FileObjectDatabase::from_git_dir(git_dir, format);
293 lookup_note_for(&db, format, tree_oid, "", &annotated.to_hex())
294}
295
296pub fn read_note(
298 git_dir: &Path,
299 format: ObjectFormat,
300 store: &FileRefStore,
301 notes_ref: &NotesRef,
302 annotated: &ObjectId,
303) -> Result<Option<ObjectId>> {
304 read_note_for(git_dir, format, store, notes_ref, annotated)
305}
306
307pub fn read_note_bytes(
309 git_dir: &Path,
310 format: ObjectFormat,
311 store: &FileRefStore,
312 notes_ref: &NotesRef,
313 annotated: &ObjectId,
314) -> Result<Option<Vec<u8>>> {
315 let Some(blob) = read_note(git_dir, format, store, notes_ref, annotated)? else {
316 return Ok(None);
317 };
318 let db = FileObjectDatabase::from_git_dir(git_dir, format);
319 let object = db.read_object(&blob)?;
320 if object.object_type != ObjectType::Blob {
321 return Err(GitError::InvalidFormat(format!(
322 "note for {} is not a blob",
323 annotated.to_hex()
324 )));
325 }
326 Ok(Some(object.body.to_vec()))
327}
328
329pub fn notes_ref_expected(store: &FileRefStore, notes_ref: &NotesRef) -> Result<Option<RefTarget>> {
333 Ok(match store.read_ref(notes_ref.as_str())? {
334 Some(RefTarget::Direct(oid)) => Some(RefTarget::Direct(oid)),
335 _ => None,
336 })
337}
338
339#[allow(clippy::too_many_arguments)]
346pub fn write_notes(
347 git_dir: &Path,
348 format: ObjectFormat,
349 store: &FileRefStore,
350 notes_ref: &NotesRef,
351 notes: &[Note],
352 message: &str,
353 identity: &NotesCommitIdentity,
354 ref_expected: Option<RefTarget>,
355) -> Result<()> {
356 commit_notes_update(
357 git_dir,
358 format,
359 store,
360 notes_ref,
361 notes,
362 message,
363 identity,
364 ref_expected,
365 )?;
366 Ok(())
367}
368
369#[allow(clippy::too_many_arguments)]
373pub fn merge_notes(
374 git_dir: &Path,
375 format: ObjectFormat,
376 store: &FileRefStore,
377 local_ref: &NotesRef,
378 remote_ref: &NotesRef,
379 strategy: NotesMergeStrategy,
380 message: &str,
381 identity: &NotesCommitIdentity,
382) -> Result<NotesMergeOutcome> {
383 let local_oid = notes_head_oid(store, local_ref)?;
384 let remote_oid = notes_head_oid(store, remote_ref)?;
385
386 match (local_oid, remote_oid) {
387 (None, None) => {
388 return Err(GitError::InvalidFormat(format!(
389 "Cannot merge empty notes ref ({}) into empty notes ref ({})",
390 remote_ref.as_str(),
391 local_ref.as_str()
392 )));
393 }
394 (None, Some(remote)) => {
395 update_notes_ref_to_commit(
396 git_dir, format, store, local_ref, None, remote, message, identity,
397 )?;
398 return Ok(NotesMergeOutcome::FastForward { result: remote });
399 }
400 (Some(local), None) => {
401 return Ok(NotesMergeOutcome::AlreadyUpToDate { result: local });
402 }
403 (Some(local), Some(remote)) if local == remote => {
404 return Ok(NotesMergeOutcome::AlreadyUpToDate { result: local });
405 }
406 _ => {}
407 }
408
409 let (Some(local_oid), Some(remote_oid)) = (local_oid, remote_oid) else {
410 return Err(GitError::InvalidFormat(
411 "missing notes merge endpoint".into(),
412 ));
413 };
414 let db = FileObjectDatabase::from_git_dir(git_dir, format);
415 let bases = merge_base_oids(&db, format, &local_oid, &remote_oid)?;
416 let base_oid = bases.first().copied();
417
418 if base_oid == Some(remote_oid) {
419 return Ok(NotesMergeOutcome::AlreadyUpToDate { result: local_oid });
420 }
421 if base_oid == Some(local_oid) {
422 update_notes_ref_to_commit(
423 git_dir,
424 format,
425 store,
426 local_ref,
427 Some(local_oid),
428 remote_oid,
429 message,
430 identity,
431 )?;
432 return Ok(NotesMergeOutcome::FastForward { result: remote_oid });
433 }
434
435 let base_tree = match base_oid {
436 Some(oid) => commit_tree_oid(&db, format, &oid)?,
437 None => ObjectId::empty_tree(format),
438 };
439 let local_tree = commit_tree_oid(&db, format, &local_oid)?;
440 let remote_tree = commit_tree_oid(&db, format, &remote_oid)?;
441
442 let base_notes = notes_map_from_tree(&db, format, base_tree)?;
443 let local_notes = notes_map_from_tree(&db, format, local_tree)?;
444 let remote_notes = notes_map_from_tree(&db, format, remote_tree)?;
445 let mut merged = local_notes.clone();
446 let mut conflicts = Vec::new();
447
448 let mut candidates: Vec<ObjectId> = base_notes
449 .keys()
450 .chain(remote_notes.keys())
451 .copied()
452 .collect();
453 candidates.sort_by_key(|oid| oid.to_hex());
454 candidates.dedup();
455
456 for annotated in candidates {
457 let base = base_notes.get(&annotated).copied();
458 let local = local_notes.get(&annotated).copied();
459 let remote = remote_notes.get(&annotated).copied();
460
461 if base == remote || local == remote {
462 continue;
463 }
464 if local == base {
465 set_note_option(&mut merged, annotated, remote);
466 continue;
467 }
468
469 match strategy {
470 NotesMergeStrategy::Manual => {
471 merged.remove(&annotated);
472 conflicts.push(NotesMergeConflict {
473 annotated,
474 base,
475 local,
476 remote,
477 });
478 }
479 NotesMergeStrategy::Ours => {}
480 NotesMergeStrategy::Theirs => set_note_option(&mut merged, annotated, remote),
481 NotesMergeStrategy::Union => {
482 if let Some(blob) =
483 combine_note_blobs(git_dir, &db, format, local, remote, NoteBlobCombine::Union)?
484 {
485 merged.insert(annotated, blob);
486 }
487 }
488 NotesMergeStrategy::CatSortUniq => {
489 if let Some(blob) = combine_note_blobs(
490 git_dir,
491 &db,
492 format,
493 local,
494 remote,
495 NoteBlobCombine::CatSortUniq,
496 )? {
497 merged.insert(annotated, blob);
498 }
499 }
500 }
501 }
502
503 let notes = notes_vec_from_map(merged);
504 let parents = vec![local_oid, remote_oid];
505 let mut commit_message = message.as_bytes().to_vec();
510 if !conflicts.is_empty() {
511 commit_message.extend_from_slice(b"\n\nConflicts:\n");
512 for conflict in &conflicts {
513 commit_message
514 .extend_from_slice(format!("\t{}\n", conflict.annotated.to_hex()).as_bytes());
515 }
516 }
517 let result = commit_notes_update_with_parents(
518 git_dir,
519 format,
520 store,
521 local_ref,
522 ¬es,
523 &commit_message,
524 identity,
525 &parents,
526 Some(RefTarget::Direct(local_oid)),
527 conflicts.is_empty(),
528 )?;
529
530 if conflicts.is_empty() {
531 Ok(NotesMergeOutcome::Merged { result })
532 } else {
533 Ok(NotesMergeOutcome::Conflicted {
534 partial: result,
535 conflicts,
536 })
537 }
538}
539
540#[allow(clippy::too_many_arguments)]
543pub fn finalize_notes_merge(
544 git_dir: &Path,
545 format: ObjectFormat,
546 store: &FileRefStore,
547 notes_ref: &NotesRef,
548 partial_commit: ObjectId,
549 resolved: &[(ObjectId, Vec<u8>)],
550 identity: &NotesCommitIdentity,
551) -> Result<ObjectId> {
552 let db = FileObjectDatabase::from_git_dir(git_dir, format);
553 let partial = read_commit(&db, format, &partial_commit)?;
554 let mut notes = notes_map_from_tree(&db, format, partial.tree)?;
555 let writable = FileObjectDatabase::from_git_dir(git_dir, format);
556 for (annotated, body) in resolved {
557 let blob = writable.write_object(EncodedObject::new(ObjectType::Blob, body.clone()))?;
558 notes.insert(*annotated, blob);
559 }
560 let expected = partial.parents.first().copied().map(RefTarget::Direct);
561 commit_notes_update_with_parents(
562 git_dir,
563 format,
564 store,
565 notes_ref,
566 ¬es_vec_from_map(notes),
567 &partial.message,
568 identity,
569 &partial.parents,
570 expected,
571 true,
572 )
573}
574
575#[allow(clippy::too_many_arguments)]
579pub fn upsert_note_for(
580 git_dir: &Path,
581 format: ObjectFormat,
582 store: &FileRefStore,
583 notes_ref: &NotesRef,
584 annotated: &ObjectId,
585 blob: ObjectId,
586 message: &str,
587 identity: &NotesCommitIdentity,
588 ref_expected: Option<RefTarget>,
589) -> Result<UpsertNoteOutcome> {
590 if let Some(existing) = read_note_for(git_dir, format, store, notes_ref, annotated)?
591 && existing == blob
592 {
593 return Ok(UpsertNoteOutcome::Unchanged);
594 }
595 let mut notes = list_notes(git_dir, format, store, notes_ref)?;
596 upsert_note(&mut notes, annotated, blob);
597 let notes_commit = commit_notes_update(
598 git_dir,
599 format,
600 store,
601 notes_ref,
602 ¬es,
603 message,
604 identity,
605 ref_expected,
606 )?;
607 Ok(UpsertNoteOutcome::Updated { notes_commit })
608}
609
610#[allow(clippy::too_many_arguments)]
612pub fn upsert_note_bytes_for(
613 git_dir: &Path,
614 format: ObjectFormat,
615 store: &FileRefStore,
616 notes_ref: &NotesRef,
617 annotated: &ObjectId,
618 body: &[u8],
619 message: &str,
620 identity: &NotesCommitIdentity,
621 ref_expected: Option<RefTarget>,
622) -> Result<UpsertNoteOutcome> {
623 let db = FileObjectDatabase::from_git_dir(git_dir, format);
624 let blob = db.write_object(EncodedObject::new(ObjectType::Blob, body.to_vec()))?;
625 upsert_note_for(
626 git_dir,
627 format,
628 store,
629 notes_ref,
630 annotated,
631 blob,
632 message,
633 identity,
634 ref_expected,
635 )
636}
637
638#[allow(clippy::too_many_arguments)]
640pub fn remove_note_for(
641 git_dir: &Path,
642 format: ObjectFormat,
643 store: &FileRefStore,
644 notes_ref: &NotesRef,
645 annotated: &ObjectId,
646 message: &str,
647 identity: &NotesCommitIdentity,
648 ref_expected: Option<RefTarget>,
649) -> Result<RemoveNoteOutcome> {
650 remove_notes_for(
651 git_dir,
652 format,
653 store,
654 notes_ref,
655 std::slice::from_ref(annotated),
656 message,
657 identity,
658 ref_expected,
659 )
660}
661
662#[allow(clippy::too_many_arguments)]
666pub fn remove_notes_for(
667 git_dir: &Path,
668 format: ObjectFormat,
669 store: &FileRefStore,
670 notes_ref: &NotesRef,
671 annotated: &[ObjectId],
672 message: &str,
673 identity: &NotesCommitIdentity,
674 ref_expected: Option<RefTarget>,
675) -> Result<RemoveNoteOutcome> {
676 if annotated.is_empty() || notes_head_oid(store, notes_ref)?.is_none() {
677 return Ok(RemoveNoteOutcome::Unchanged);
678 }
679 let targets: HashSet<_> = annotated.iter().collect();
680 let mut notes = list_notes(git_dir, format, store, notes_ref)?;
681 let before = notes.len();
682 notes.retain(|note| !targets.contains(¬e.annotated));
683 if notes.len() == before {
684 return Ok(RemoveNoteOutcome::Unchanged);
685 }
686 let notes_commit = commit_notes_update(
687 git_dir,
688 format,
689 store,
690 notes_ref,
691 ¬es,
692 message,
693 identity,
694 ref_expected,
695 )?;
696 Ok(RemoveNoteOutcome::Removed { notes_commit })
697}
698
699pub fn upsert_note(notes: &mut Vec<Note>, annotated: &ObjectId, blob: ObjectId) {
701 let target_hex = annotated.to_hex();
702 if let Some(existing) = notes
703 .iter_mut()
704 .find(|entry| entry.annotated.to_hex() == target_hex)
705 {
706 existing.blob = blob;
707 } else {
708 notes.push(Note {
709 annotated: *annotated,
710 blob,
711 });
712 }
713}
714
715pub fn remove_note(notes: &mut Vec<Note>, annotated: &ObjectId) {
717 let target_hex = annotated.to_hex();
718 notes.retain(|entry| entry.annotated.to_hex() != target_hex);
719}
720
721pub fn notes_tree_oid(
723 git_dir: &Path,
724 format: ObjectFormat,
725 store: &FileRefStore,
726 notes_ref: &NotesRef,
727) -> Result<Option<ObjectId>> {
728 let Some(target) = store.read_ref(notes_ref.as_str())? else {
729 return Ok(None);
730 };
731 let commit_oid = match target {
732 RefTarget::Direct(oid) => oid,
733 RefTarget::Symbolic(name) => match store.read_ref(&name)? {
734 Some(RefTarget::Direct(oid)) => oid,
735 _ => return Ok(None),
736 },
737 };
738 let db = FileObjectDatabase::from_git_dir(git_dir, format);
739 let object = db.read_object(&commit_oid)?;
740 match object.object_type {
741 ObjectType::Commit => Ok(Some(Commit::parse_ref(format, &object.body)?.tree)),
742 ObjectType::Tree => Ok(Some(commit_oid)),
743 _ => Ok(None),
744 }
745}
746
747fn load_hex_tree_entries(
748 db: &FileObjectDatabase,
749 format: ObjectFormat,
750 tree_oid: &ObjectId,
751) -> Result<Vec<(String, u32, ObjectId)>> {
752 let object = db.read_object(tree_oid)?;
753 if object.object_type != ObjectType::Tree {
754 return Ok(Vec::new());
755 }
756 let mut out = Vec::new();
757 for entry in TreeEntries::new(format, &object.body) {
758 let entry = entry?;
759 let Ok(name) = std::str::from_utf8(entry.name) else {
760 continue;
761 };
762 if !is_hex_name(name) {
763 continue;
764 }
765 out.push((name.to_string(), entry.mode, entry.oid));
766 }
767 Ok(out)
768}
769
770fn lookup_note_for(
771 db: &FileObjectDatabase,
772 format: ObjectFormat,
773 tree_oid: &ObjectId,
774 prefix: &str,
775 target_hex: &str,
776) -> Result<Option<ObjectId>> {
777 for (name, mode, oid) in load_hex_tree_entries(db, format, tree_oid)? {
778 let mut hex = prefix.to_string();
779 hex.push_str(&name);
780 if tree_entry_object_type(mode) == ObjectType::Tree {
781 if !target_hex.starts_with(&hex) {
782 continue;
783 }
784 if let Some(blob) = lookup_note_for(db, format, &oid, &hex, target_hex)? {
785 return Ok(Some(blob));
786 }
787 } else if hex == target_hex {
788 return Ok(Some(oid));
789 }
790 }
791 Ok(None)
792}
793
794fn is_hex_name(name: &str) -> bool {
795 !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_hexdigit())
796}
797
798fn expand_notes_ref(name: &str) -> String {
799 if name.starts_with("refs/notes/") {
800 name.to_string()
801 } else {
802 format!("refs/notes/{name}")
803 }
804}
805
806fn read_repo_config(git_dir: &Path) -> Result<GitConfig> {
812 sley_config::read_repo_config(git_dir, None)
813}
814
815fn read_commit(db: &FileObjectDatabase, format: ObjectFormat, oid: &ObjectId) -> Result<Commit> {
816 let object = db.read_object(oid)?;
817 if object.object_type != ObjectType::Commit {
818 return Err(GitError::InvalidFormat(format!(
819 "{} is not a commit",
820 oid.to_hex()
821 )));
822 }
823 Commit::parse(format, &object.body)
824}
825
826fn commit_tree_oid(
827 db: &FileObjectDatabase,
828 format: ObjectFormat,
829 oid: &ObjectId,
830) -> Result<ObjectId> {
831 Ok(read_commit(db, format, oid)?.tree)
832}
833
834fn merge_base_oids(
835 db: &FileObjectDatabase,
836 format: ObjectFormat,
837 left: &ObjectId,
838 right: &ObjectId,
839) -> Result<Vec<ObjectId>> {
840 let left_depths = ancestor_depths(db, format, left)?;
841 let right_depths = ancestor_depths(db, format, right)?;
842 let candidates: Vec<ObjectId> = left_depths
843 .keys()
844 .filter(|oid| right_depths.contains_key(*oid))
845 .copied()
846 .collect();
847 let mut bases: Vec<ObjectId> = candidates
848 .iter()
849 .filter(|candidate| {
850 !candidates.iter().any(|other| {
851 other != *candidate
852 && depth_lt(&left_depths, other, candidate)
853 && depth_lt(&right_depths, other, candidate)
854 })
855 })
856 .copied()
857 .collect();
858 bases.sort_by_key(|oid| oid.to_hex());
859 Ok(bases)
860}
861
862fn ancestor_depths(
863 db: &FileObjectDatabase,
864 format: ObjectFormat,
865 start: &ObjectId,
866) -> Result<HashMap<ObjectId, usize>> {
867 let mut depths = HashMap::new();
868 let mut pending = VecDeque::from([(*start, 0usize)]);
869 while let Some((oid, depth)) = pending.pop_front() {
870 if depths.get(&oid).is_some_and(|seen| *seen <= depth) {
871 continue;
872 }
873 depths.insert(oid, depth);
874 for parent in read_commit(db, format, &oid)?.parents {
875 pending.push_back((parent, depth + 1));
876 }
877 }
878 Ok(depths)
879}
880
881fn depth_lt(depths: &HashMap<ObjectId, usize>, left: &ObjectId, right: &ObjectId) -> bool {
882 match (depths.get(left), depths.get(right)) {
883 (Some(left), Some(right)) => left < right,
884 _ => false,
885 }
886}
887
888fn notes_head_oid(store: &FileRefStore, notes_ref: &NotesRef) -> Result<Option<ObjectId>> {
889 Ok(match store.read_ref(notes_ref.as_str())? {
890 Some(RefTarget::Direct(oid)) => Some(oid),
891 _ => None,
892 })
893}
894
895fn notes_map_from_tree(
896 db: &FileObjectDatabase,
897 format: ObjectFormat,
898 tree_oid: ObjectId,
899) -> Result<BTreeMap<ObjectId, ObjectId>> {
900 let mut notes = BTreeMap::new();
901 if tree_oid == ObjectId::empty_tree(format) {
902 return Ok(notes);
903 }
904 collect_notes_from_tree(db, format, tree_oid, "", &mut notes)?;
905 Ok(notes)
906}
907
908fn collect_notes_from_tree(
909 db: &FileObjectDatabase,
910 format: ObjectFormat,
911 tree_oid: ObjectId,
912 prefix: &str,
913 out: &mut BTreeMap<ObjectId, ObjectId>,
914) -> Result<()> {
915 for (name, mode, oid) in load_hex_tree_entries(db, format, &tree_oid)? {
916 let mut hex = prefix.to_string();
917 hex.push_str(&name);
918 if tree_entry_object_type(mode) == ObjectType::Tree {
919 collect_notes_from_tree(db, format, oid, &hex, out)?;
920 } else if hex.len() == format.hex_len()
921 && let Ok(annotated) = ObjectId::from_hex(format, &hex)
922 {
923 out.insert(annotated, oid);
924 }
925 }
926 Ok(())
927}
928
929fn notes_vec_from_map(notes: BTreeMap<ObjectId, ObjectId>) -> Vec<Note> {
930 notes
931 .into_iter()
932 .map(|(annotated, blob)| Note { annotated, blob })
933 .collect()
934}
935
936fn set_note_option(
937 notes: &mut BTreeMap<ObjectId, ObjectId>,
938 annotated: ObjectId,
939 blob: Option<ObjectId>,
940) {
941 match blob {
942 Some(blob) => {
943 notes.insert(annotated, blob);
944 }
945 None => {
946 notes.remove(&annotated);
947 }
948 }
949}
950
951enum NoteBlobCombine {
952 Union,
953 CatSortUniq,
954}
955
956fn combine_note_blobs(
957 git_dir: &Path,
958 db: &FileObjectDatabase,
959 format: ObjectFormat,
960 local: Option<ObjectId>,
961 remote: Option<ObjectId>,
962 mode: NoteBlobCombine,
963) -> Result<Option<ObjectId>> {
964 match mode {
965 NoteBlobCombine::Union => combine_note_blobs_union(git_dir, db, format, local, remote),
966 NoteBlobCombine::CatSortUniq => {
967 combine_note_blobs_cat_sort_uniq(git_dir, db, format, local, remote)
968 }
969 }
970}
971
972fn read_blob_bytes(db: &FileObjectDatabase, oid: &ObjectId) -> Result<Option<Vec<u8>>> {
973 let object = db.read_object(oid)?;
974 if object.object_type != ObjectType::Blob || object.body.is_empty() {
975 return Ok(None);
976 }
977 Ok(Some(object.body.clone()))
978}
979
980fn combine_note_blobs_union(
981 git_dir: &Path,
982 db: &FileObjectDatabase,
983 format: ObjectFormat,
984 local: Option<ObjectId>,
985 remote: Option<ObjectId>,
986) -> Result<Option<ObjectId>> {
987 let Some(remote_oid) = remote else {
988 return Ok(local);
989 };
990 let Some(remote_body) = read_blob_bytes(db, &remote_oid)? else {
991 return Ok(local);
992 };
993 let Some(local_oid) = local else {
994 return Ok(Some(remote_oid));
995 };
996 let Some(mut local_body) = read_blob_bytes(db, &local_oid)? else {
997 return Ok(Some(remote_oid));
998 };
999 if local_body.last() == Some(&b'\n') {
1000 local_body.pop();
1001 }
1002 local_body.extend_from_slice(b"\n\n");
1003 local_body.extend_from_slice(&remote_body);
1004 let writable = FileObjectDatabase::from_git_dir(git_dir, format);
1005 writable
1006 .write_object(EncodedObject::new(ObjectType::Blob, local_body))
1007 .map(Some)
1008}
1009
1010fn combine_note_blobs_cat_sort_uniq(
1011 git_dir: &Path,
1012 db: &FileObjectDatabase,
1013 format: ObjectFormat,
1014 local: Option<ObjectId>,
1015 remote: Option<ObjectId>,
1016) -> Result<Option<ObjectId>> {
1017 let mut lines: Vec<Vec<u8>> = Vec::new();
1018 for oid in [local, remote].into_iter().flatten() {
1019 if let Some(body) = read_blob_bytes(db, &oid)? {
1020 lines.extend(body.split(|byte| *byte == b'\n').map(|line| line.to_vec()));
1021 }
1022 }
1023 lines.retain(|line| !line.is_empty());
1024 if lines.is_empty() {
1025 return Ok(None);
1026 }
1027 lines.sort();
1028 lines.dedup();
1029 let mut body = Vec::new();
1030 for line in lines {
1031 body.extend_from_slice(&line);
1032 body.push(b'\n');
1033 }
1034 let writable = FileObjectDatabase::from_git_dir(git_dir, format);
1035 writable
1036 .write_object(EncodedObject::new(ObjectType::Blob, body))
1037 .map(Some)
1038}
1039
1040#[allow(clippy::too_many_arguments)]
1041fn commit_notes_update(
1042 git_dir: &Path,
1043 format: ObjectFormat,
1044 store: &FileRefStore,
1045 notes_ref: &NotesRef,
1046 notes: &[Note],
1047 message: &str,
1048 identity: &NotesCommitIdentity,
1049 ref_expected: Option<RefTarget>,
1050) -> Result<ObjectId> {
1051 let parent = notes_head_oid(store, notes_ref)?;
1052 let parents = parent.iter().cloned().collect::<Vec<_>>();
1053 commit_notes_update_with_parents(
1054 git_dir,
1055 format,
1056 store,
1057 notes_ref,
1058 notes,
1059 format!("{message}\n").as_bytes(),
1060 identity,
1061 &parents,
1062 ref_expected,
1063 true,
1064 )
1065}
1066
1067#[allow(clippy::too_many_arguments)]
1068fn commit_notes_update_with_parents(
1069 git_dir: &Path,
1070 format: ObjectFormat,
1071 store: &FileRefStore,
1072 notes_ref: &NotesRef,
1073 notes: &[Note],
1074 message: &[u8],
1075 identity: &NotesCommitIdentity,
1076 parents: &[ObjectId],
1077 ref_expected: Option<RefTarget>,
1078 update_ref: bool,
1079) -> Result<ObjectId> {
1080 let mut db = FileObjectDatabase::from_git_dir(git_dir, format);
1081 let non_notes = match parents.first() {
1087 Some(parent) => collect_non_notes_from_commit(&db, format, parent)?,
1088 None => Vec::new(),
1089 };
1090 let tree_oid = write_notes_tree_preserving(&mut db, notes, &non_notes)?;
1091
1092 let commit_oid = create_commit(
1093 &mut db,
1094 CommitCreate {
1095 tree: tree_oid,
1096 parents: parents.to_vec(),
1097 author: identity.author.clone(),
1098 committer: identity.committer.clone(),
1099 message: message.to_vec(),
1100 encoding: None,
1101 signature: None,
1102 },
1103 )?;
1104
1105 if !update_ref {
1106 return Ok(commit_oid);
1107 }
1108 let old_oid = parents.first().copied().unwrap_or(zero_oid(format)?);
1109 let mut tx = store.transaction();
1110 let reflog_message = reflog_message_from_commit_message(message);
1111 tx.update(RefUpdate {
1112 name: notes_ref.as_str().to_string(),
1113 expected: ref_expected,
1114 new: RefTarget::Direct(commit_oid),
1115 reflog: Some(ReflogEntry {
1116 old_oid,
1117 new_oid: commit_oid,
1118 committer: identity.committer.clone(),
1119 message: reflog_message,
1122 }),
1123 });
1124 tx.commit()?;
1125 Ok(commit_oid)
1126}
1127
1128fn update_notes_ref_to_commit(
1129 git_dir: &Path,
1130 format: ObjectFormat,
1131 store: &FileRefStore,
1132 notes_ref: &NotesRef,
1133 old: Option<ObjectId>,
1134 new: ObjectId,
1135 message: &str,
1136 identity: &NotesCommitIdentity,
1137) -> Result<()> {
1138 let old_oid = old.unwrap_or(zero_oid(format)?);
1139 let mut tx = store.transaction();
1140 tx.update(RefUpdate {
1141 name: notes_ref.as_str().to_string(),
1142 expected: old.map(RefTarget::Direct),
1143 new: RefTarget::Direct(new),
1144 reflog: Some(ReflogEntry {
1145 old_oid,
1146 new_oid: new,
1147 committer: identity.committer.clone(),
1148 message: format!("notes: {message}").into_bytes(),
1149 }),
1150 });
1151 let _ = git_dir;
1152 tx.commit()
1153}
1154
1155fn reflog_message_from_commit_message(message: &[u8]) -> Vec<u8> {
1156 let subject = message
1157 .split(|byte| *byte == b'\n')
1158 .next()
1159 .unwrap_or(message);
1160 let mut out = b"notes: ".to_vec();
1161 out.extend_from_slice(subject);
1162 out
1163}
1164
1165fn write_notes_tree(db: &mut FileObjectDatabase, notes: &[Note]) -> Result<ObjectId> {
1166 if notes.len() >= 256 {
1167 write_fanout_notes_tree(db, notes)
1168 } else {
1169 write_flat_notes_tree(db, notes)
1170 }
1171}
1172
1173fn write_flat_notes_tree(db: &mut FileObjectDatabase, notes: &[Note]) -> Result<ObjectId> {
1174 let mut entries: Vec<TreeEntry> = notes
1175 .iter()
1176 .map(|note| TreeEntry {
1177 mode: 0o100644,
1178 name: BString::from(note.annotated.to_hex().as_bytes()),
1179 oid: note.blob,
1180 })
1181 .collect();
1182 entries.sort_by(|left, right| left.name.cmp(&right.name));
1183 db.write_object(EncodedObject::new(
1184 ObjectType::Tree,
1185 Tree { entries }.write(),
1186 ))
1187}
1188
1189fn write_fanout_notes_tree(db: &mut FileObjectDatabase, notes: &[Note]) -> Result<ObjectId> {
1190 let mut groups: BTreeMap<String, Vec<TreeEntry>> = BTreeMap::new();
1191 for note in notes {
1192 let hex = note.annotated.to_hex();
1193 let (prefix, suffix) = hex.split_at(2);
1194 groups
1195 .entry(prefix.to_string())
1196 .or_default()
1197 .push(TreeEntry {
1198 mode: 0o100644,
1199 name: BString::from(suffix.as_bytes()),
1200 oid: note.blob,
1201 });
1202 }
1203
1204 let mut root_entries = Vec::new();
1205 for (prefix, mut entries) in groups {
1206 entries.sort_by(|left, right| left.name.cmp(&right.name));
1207 let subtree_oid = db.write_object(EncodedObject::new(
1208 ObjectType::Tree,
1209 Tree { entries }.write(),
1210 ))?;
1211 root_entries.push(TreeEntry {
1212 mode: 0o040000,
1213 name: BString::from(prefix.as_bytes()),
1214 oid: subtree_oid,
1215 });
1216 }
1217 root_entries.sort_by(|left, right| left.name.cmp(&right.name));
1218 db.write_object(EncodedObject::new(
1219 ObjectType::Tree,
1220 Tree {
1221 entries: root_entries,
1222 }
1223 .write(),
1224 ))
1225}
1226
1227type PathEntry = (Vec<u8>, u32, ObjectId);
1229
1230#[derive(Debug, Clone)]
1235struct NonNote {
1236 path: Vec<u8>,
1238 mode: u32,
1239 oid: ObjectId,
1240}
1241
1242fn collect_non_notes_from_commit(
1248 db: &FileObjectDatabase,
1249 format: ObjectFormat,
1250 commit_oid: &ObjectId,
1251) -> Result<Vec<NonNote>> {
1252 let object = db.read_object(commit_oid)?;
1253 if object.object_type != ObjectType::Commit {
1254 return Ok(Vec::new());
1255 }
1256 let commit = Commit::parse(format, &object.body)?;
1257 let mut out = Vec::new();
1258 collect_non_notes_rec(db, format, &commit.tree, &[], 0, &mut out)?;
1259 Ok(out)
1260}
1261
1262fn collect_non_notes_rec(
1263 db: &FileObjectDatabase,
1264 format: ObjectFormat,
1265 tree_oid: &ObjectId,
1266 prefix: &[u8],
1267 consumed_hex: usize,
1268 out: &mut Vec<NonNote>,
1269) -> Result<()> {
1270 let object = db.read_object(tree_oid)?;
1271 if object.object_type != ObjectType::Tree {
1272 return Ok(());
1273 }
1274 let hex_len = format.hex_len();
1275 for entry in TreeEntries::new(format, &object.body) {
1276 let entry = entry?;
1277 let name = entry.name;
1278 let name_len = name.len();
1279 let is_tree = tree_entry_object_type(entry.mode) == ObjectType::Tree;
1280 let is_hex = !name.is_empty() && name.iter().all(u8::is_ascii_hexdigit);
1281
1282 if consumed_hex < hex_len && name_len == hex_len - consumed_hex {
1283 if !is_tree && is_hex {
1285 continue;
1286 }
1287 } else if name_len == 2 && is_tree && is_hex {
1288 let mut child_prefix = prefix.to_vec();
1290 child_prefix.extend_from_slice(name);
1291 child_prefix.push(b'/');
1292 collect_non_notes_rec(db, format, &entry.oid, &child_prefix, consumed_hex + 2, out)?;
1293 continue;
1294 }
1295
1296 let mut full = prefix.to_vec();
1298 full.extend_from_slice(name);
1299 out.push(NonNote {
1300 path: full,
1301 mode: entry.mode,
1302 oid: entry.oid,
1303 });
1304 }
1305 Ok(())
1306}
1307
1308fn write_notes_tree_preserving(
1312 db: &mut FileObjectDatabase,
1313 notes: &[Note],
1314 non_notes: &[NonNote],
1315) -> Result<ObjectId> {
1316 if non_notes.is_empty() {
1317 return write_notes_tree(db, notes);
1318 }
1319 write_woven_notes_tree(db, notes, non_notes)
1320}
1321
1322fn note_disk_paths(notes: &[Note]) -> Vec<PathEntry> {
1325 if notes.len() >= 256 {
1326 notes
1327 .iter()
1328 .map(|note| {
1329 let hex = note.annotated.to_hex();
1330 let (prefix, suffix) = hex.split_at(2);
1331 let mut path = prefix.as_bytes().to_vec();
1332 path.push(b'/');
1333 path.extend_from_slice(suffix.as_bytes());
1334 (path, 0o100644u32, note.blob)
1335 })
1336 .collect()
1337 } else {
1338 notes
1339 .iter()
1340 .map(|note| (note.annotated.to_hex().into_bytes(), 0o100644u32, note.blob))
1341 .collect()
1342 }
1343}
1344
1345fn write_woven_notes_tree(
1346 db: &mut FileObjectDatabase,
1347 notes: &[Note],
1348 non_notes: &[NonNote],
1349) -> Result<ObjectId> {
1350 let mut paths: BTreeMap<Vec<u8>, (u32, ObjectId)> = BTreeMap::new();
1353 for (path, mode, oid) in note_disk_paths(notes) {
1354 paths.insert(path, (mode, oid));
1355 }
1356 for non_note in non_notes {
1357 paths
1358 .entry(non_note.path.clone())
1359 .or_insert((non_note.mode, non_note.oid));
1360 }
1361 let entries: Vec<PathEntry> = paths
1362 .into_iter()
1363 .map(|(path, (mode, oid))| (path, mode, oid))
1364 .collect();
1365 build_nested_tree(db, &entries)
1366}
1367
1368fn build_nested_tree(db: &mut FileObjectDatabase, entries: &[PathEntry]) -> Result<ObjectId> {
1372 let mut builder = TreeBuilder::new();
1373 let mut subdirs: BTreeMap<Vec<u8>, Vec<PathEntry>> = BTreeMap::new();
1374 for (path, mode, oid) in entries {
1375 match path.iter().position(|byte| *byte == b'/') {
1376 None => builder.upsert_raw(path.clone(), *mode, *oid),
1377 Some(slash) => {
1378 let component = path[..slash].to_vec();
1379 let rest = path[slash + 1..].to_vec();
1380 subdirs
1381 .entry(component)
1382 .or_default()
1383 .push((rest, *mode, *oid));
1384 }
1385 }
1386 }
1387 for (component, children) in subdirs {
1388 let subtree_oid = build_nested_tree(db, &children)?;
1389 builder.upsert_raw(component, 0o040000, subtree_oid);
1390 }
1391 db.write_object(EncodedObject::new(
1392 ObjectType::Tree,
1393 builder.build().write(),
1394 ))
1395}
1396
1397fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
1398 ObjectId::from_hex(format, &"0".repeat(format.hex_len()))
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403 use super::*;
1404 use sley_sequencer::format_commit_identity;
1405 use std::fs;
1406 use std::path::{Path, PathBuf};
1407 use std::process::{Command, Stdio};
1408 use std::time::{SystemTime, UNIX_EPOCH};
1409
1410 const NAME: &str = "Tester";
1411 const EMAIL: &str = "tester@example.com";
1412 const DATE: &str = "@1790000000 -0500";
1413
1414 fn unique_temp_dir(name: &str) -> PathBuf {
1415 let nanos = SystemTime::now()
1416 .duration_since(UNIX_EPOCH)
1417 .expect("system time before unix epoch")
1418 .as_nanos();
1419 std::env::temp_dir().join(format!("sley-notes-{name}-{}-{nanos}", std::process::id()))
1420 }
1421
1422 fn git_available() -> bool {
1423 Command::new("git")
1424 .arg("--version")
1425 .stdout(Stdio::null())
1426 .stderr(Stdio::null())
1427 .status()
1428 .map(|status| status.success())
1429 .unwrap_or(false)
1430 }
1431
1432 fn test_identity() -> NotesCommitIdentity {
1433 NotesCommitIdentity {
1434 author: format_commit_identity(NAME, EMAIL, DATE)
1435 .expect("test operation should succeed"),
1436 committer: format_commit_identity(NAME, EMAIL, DATE)
1437 .expect("test operation should succeed"),
1438 }
1439 }
1440
1441 fn git_env(command: &mut Command) -> &mut Command {
1442 command
1443 .env("GIT_AUTHOR_NAME", NAME)
1444 .env("GIT_AUTHOR_EMAIL", EMAIL)
1445 .env("GIT_AUTHOR_DATE", DATE)
1446 .env("GIT_COMMITTER_NAME", NAME)
1447 .env("GIT_COMMITTER_EMAIL", EMAIL)
1448 .env("GIT_COMMITTER_DATE", DATE)
1449 }
1450
1451 fn init_repo_with_commit(root: &Path) -> (PathBuf, ObjectId) {
1452 let mut init = Command::new("git");
1453 git_env(init.current_dir(root).args(["init", "-q"]))
1454 .status()
1455 .expect("git init should succeed");
1456 fs::write(root.join("f.txt"), b"content\n").expect("write worktree file");
1457 let mut add = Command::new("git");
1458 git_env(add.current_dir(root).args(["add", "f.txt"]))
1459 .status()
1460 .expect("git add should succeed");
1461 let mut commit = Command::new("git");
1462 git_env(commit.current_dir(root).args(["commit", "-q", "-m", "c1"]))
1463 .status()
1464 .expect("git commit should succeed");
1465 let git_dir = root.join(".git");
1466 let format = ObjectFormat::Sha1;
1467 let store = FileRefStore::new(&git_dir, format);
1468 let head = store
1469 .read_ref("HEAD")
1470 .expect("read HEAD")
1471 .expect("HEAD should exist");
1472 let oid = match head {
1473 RefTarget::Direct(oid) => oid,
1474 RefTarget::Symbolic(name) => match store.read_ref(&name).expect("read symref") {
1475 Some(RefTarget::Direct(oid)) => oid,
1476 other => panic!("unexpected symref target: {other:?}"),
1477 },
1478 };
1479 (git_dir, oid)
1480 }
1481
1482 fn write_blob(db: &mut FileObjectDatabase, bytes: &[u8]) -> Result<ObjectId> {
1483 db.write_object(EncodedObject::new(ObjectType::Blob, bytes.to_vec()))
1484 }
1485
1486 #[test]
1487 fn notes_ref_expand_qualifies_names() {
1488 assert_eq!(NotesRef::expand("commits").as_str(), "refs/notes/commits");
1489 assert_eq!(
1490 NotesRef::expand("refs/notes/review").as_str(),
1491 "refs/notes/review"
1492 );
1493 }
1494
1495 #[test]
1496 fn read_write_list_round_trip() {
1497 let dir = unique_temp_dir("round-trip");
1498 fs::create_dir_all(&dir).expect("create temp dir");
1499 let (git_dir, target) = init_repo_with_commit(&dir);
1500 let format = ObjectFormat::Sha1;
1501 let store = FileRefStore::new(&git_dir, format);
1502 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1503 let identity = test_identity();
1504 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1505 let blob = write_blob(&mut db, b"hello note\n").expect("test operation should succeed");
1506
1507 let mut notes = Vec::new();
1508 upsert_note(&mut notes, &target, blob);
1509 write_notes(
1510 &git_dir,
1511 format,
1512 &store,
1513 ¬es_ref,
1514 ¬es,
1515 "Notes added by test",
1516 &identity,
1517 notes_ref_expected(&store, ¬es_ref).expect("ref expected"),
1518 )
1519 .expect("test operation should succeed");
1520
1521 let listed = list_notes(&git_dir, format, &store, ¬es_ref)
1522 .expect("test operation should succeed");
1523 assert_eq!(listed.len(), 1);
1524 assert_eq!(listed[0].annotated, target);
1525 assert_eq!(listed[0].blob, blob);
1526
1527 let read_back = read_note(&git_dir, format, &store, ¬es_ref, &target)
1528 .expect("test operation should succeed");
1529 assert_eq!(read_back, Some(blob));
1530
1531 let bytes = read_note_bytes(&git_dir, format, &store, ¬es_ref, &target)
1532 .expect("test operation should succeed");
1533 assert_eq!(bytes.as_deref(), Some(b"hello note\n" as &[u8]));
1534 let _ = fs::remove_dir_all(&dir);
1535 }
1536
1537 #[test]
1538 fn iter_notes_matches_list_notes() {
1539 let dir = unique_temp_dir("iter-list");
1540 fs::create_dir_all(&dir).expect("create temp dir");
1541 let (git_dir, target) = init_repo_with_commit(&dir);
1542 let format = ObjectFormat::Sha1;
1543 let store = FileRefStore::new(&git_dir, format);
1544 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1545 let blob = write_blob(&mut db, b"iter note\n").expect("blob");
1546 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1547 write_notes(
1548 &git_dir,
1549 format,
1550 &store,
1551 ¬es_ref,
1552 &[Note {
1553 annotated: target,
1554 blob,
1555 }],
1556 "note",
1557 &test_identity(),
1558 notes_ref_expected(&store, ¬es_ref).expect("ref expected"),
1559 )
1560 .expect("write notes");
1561
1562 let listed = list_notes(&git_dir, format, &store, ¬es_ref).expect("list");
1563 let mut iter_collected = iter_notes(&git_dir, format, &store, ¬es_ref)
1564 .expect("iter")
1565 .collect::<Result<Vec<_>>>()
1566 .expect("collect");
1567 iter_collected.sort_by_key(|entry| entry.annotated.to_hex());
1568 assert_eq!(listed, iter_collected);
1569 let _ = fs::remove_dir_all(&dir);
1570 }
1571
1572 #[test]
1573 fn iter_notes_yields_every_note_in_flat_tree() {
1574 let dir = unique_temp_dir("iter-flat-multi");
1575 fs::create_dir_all(&dir).expect("create temp dir");
1576 let (git_dir, _) = init_repo_with_commit(&dir);
1577 let format = ObjectFormat::Sha1;
1578 let store = FileRefStore::new(&git_dir, format);
1579 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1580 let first =
1581 ObjectId::from_hex(format, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("oid");
1582 let second =
1583 ObjectId::from_hex(format, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").expect("oid");
1584 let blob_a = write_blob(&mut db, b"note a\n").expect("blob");
1585 let blob_b = write_blob(&mut db, b"note b\n").expect("blob");
1586 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1587 write_notes(
1588 &git_dir,
1589 format,
1590 &store,
1591 ¬es_ref,
1592 &[
1593 Note {
1594 annotated: first,
1595 blob: blob_a,
1596 },
1597 Note {
1598 annotated: second,
1599 blob: blob_b,
1600 },
1601 ],
1602 "notes",
1603 &test_identity(),
1604 notes_ref_expected(&store, ¬es_ref).expect("ref expected"),
1605 )
1606 .expect("write notes");
1607
1608 let collected = iter_notes(&git_dir, format, &store, ¬es_ref)
1609 .expect("iter")
1610 .collect::<Result<Vec<_>>>()
1611 .expect("collect");
1612 assert_eq!(collected.len(), 2);
1613 let _ = fs::remove_dir_all(&dir);
1614 }
1615
1616 #[test]
1617 fn read_note_for_skips_unrelated_fanout_branches() {
1618 let dir = unique_temp_dir("lookup");
1619 fs::create_dir_all(&dir).expect("create temp dir");
1620 let (git_dir, target) = init_repo_with_commit(&dir);
1621 let format = ObjectFormat::Sha1;
1622 let store = FileRefStore::new(&git_dir, format);
1623 let target_hex = target.to_hex();
1624 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1625 let blob = write_blob(&mut db, b"lookup note\n").expect("blob");
1626 let prefix = &target_hex[..2];
1627 let suffix = &target_hex[2..];
1628 let leaf = Tree {
1629 entries: vec![TreeEntry {
1630 mode: 0o100644,
1631 name: BString::from(suffix.as_bytes()),
1632 oid: blob,
1633 }],
1634 };
1635 let leaf_oid = db
1636 .write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
1637 .expect("leaf");
1638 let fanout = Tree {
1639 entries: vec![TreeEntry {
1640 mode: 0o040000,
1641 name: BString::from(prefix.as_bytes()),
1642 oid: leaf_oid,
1643 }],
1644 };
1645 let fanout_oid = db
1646 .write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
1647 .expect("fanout");
1648 let identity = test_identity();
1649 let commit_oid = create_commit(
1650 &mut db,
1651 CommitCreate {
1652 tree: fanout_oid,
1653 parents: Vec::new(),
1654 author: identity.author.clone(),
1655 committer: identity.committer.clone(),
1656 message: b"fanout notes\n".to_vec(),
1657 encoding: None,
1658 signature: None,
1659 },
1660 )
1661 .expect("commit");
1662 let mut tx = store.transaction();
1663 tx.update(RefUpdate {
1664 name: DEFAULT_NOTES_REF.to_string(),
1665 expected: None,
1666 new: RefTarget::Direct(commit_oid),
1667 reflog: None,
1668 });
1669 tx.commit().expect("update ref");
1670 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1671 let found = read_note_for(&git_dir, format, &store, ¬es_ref, &target).expect("lookup");
1672 assert_eq!(found, Some(blob));
1673 let _ = fs::remove_dir_all(&dir);
1674 }
1675
1676 #[test]
1677 fn fanout_tree_is_readable() {
1678 let dir = unique_temp_dir("fanout");
1679 fs::create_dir_all(&dir).expect("create temp dir");
1680 let (git_dir, target) = init_repo_with_commit(&dir);
1681 let format = ObjectFormat::Sha1;
1682 let store = FileRefStore::new(&git_dir, format);
1683 let target_hex = target.to_hex();
1684 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1685 let blob = write_blob(&mut db, b"fanout note\n").expect("test operation should succeed");
1686
1687 let prefix = &target_hex[..2];
1689 let suffix = &target_hex[2..];
1690 let leaf = Tree {
1691 entries: vec![TreeEntry {
1692 mode: 0o100644,
1693 name: BString::from(suffix.as_bytes()),
1694 oid: blob,
1695 }],
1696 };
1697 let leaf_oid = db
1698 .write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
1699 .expect("test operation should succeed");
1700 let fanout = Tree {
1701 entries: vec![TreeEntry {
1702 mode: 0o040000,
1703 name: BString::from(prefix.as_bytes()),
1704 oid: leaf_oid,
1705 }],
1706 };
1707 let fanout_oid = db
1708 .write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
1709 .expect("test operation should succeed");
1710
1711 let identity = test_identity();
1712 let commit_oid = create_commit(
1713 &mut db,
1714 CommitCreate {
1715 tree: fanout_oid,
1716 parents: Vec::new(),
1717 author: identity.author.clone(),
1718 committer: identity.committer.clone(),
1719 message: b"fanout notes\n".to_vec(),
1720 encoding: None,
1721 signature: None,
1722 },
1723 )
1724 .expect("test operation should succeed");
1725 let mut tx = store.transaction();
1726 tx.update(RefUpdate {
1727 name: DEFAULT_NOTES_REF.to_string(),
1728 expected: None,
1729 new: RefTarget::Direct(commit_oid),
1730 reflog: None,
1731 });
1732 tx.commit().expect("test operation should succeed");
1733
1734 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1735 let read_back = read_note(&git_dir, format, &store, ¬es_ref, &target)
1736 .expect("test operation should succeed");
1737 assert_eq!(read_back, Some(blob));
1738 let _ = fs::remove_dir_all(&dir);
1739 }
1740
1741 #[test]
1742 fn note_bytes_match_system_git() {
1743 if !git_available() {
1744 return;
1745 }
1746 let dir = unique_temp_dir("git-interop");
1747 fs::create_dir_all(&dir).expect("test operation should succeed");
1748 let result = std::panic::catch_unwind(|| {
1749 let (git_dir, target) = init_repo_with_commit(&dir);
1750 let format = ObjectFormat::Sha1;
1751 let store = FileRefStore::new(&git_dir, format);
1752 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1753
1754 let mut git_add_cmd = Command::new("git");
1755 let git_add = git_env(git_add_cmd.current_dir(&dir).args([
1756 "notes",
1757 "add",
1758 "-m",
1759 "interop note",
1760 "HEAD",
1761 ]))
1762 .output()
1763 .expect("git notes add should run");
1764 assert!(
1765 git_add.status.success(),
1766 "git notes add failed: {}",
1767 String::from_utf8_lossy(&git_add.stderr)
1768 );
1769
1770 let sley_bytes = read_note_bytes(&git_dir, format, &store, ¬es_ref, &target)
1771 .expect("test operation should succeed")
1772 .expect("note should exist");
1773
1774 let mut git_show_cmd = Command::new("git");
1775 let git_output = git_env(
1776 git_show_cmd
1777 .current_dir(&dir)
1778 .args(["notes", "show", "HEAD"]),
1779 )
1780 .output()
1781 .expect("test operation should succeed");
1782 assert!(
1783 git_output.status.success(),
1784 "git notes show failed: {}",
1785 String::from_utf8_lossy(&git_output.stderr)
1786 );
1787 assert_eq!(sley_bytes, git_output.stdout);
1788 });
1789 let _ = fs::remove_dir_all(&dir);
1790 result.expect("note_bytes_match_system_git assertions");
1791 }
1792
1793 fn heddle_notes_ref() -> NotesRef {
1794 NotesRef::expand("refs/notes/heddle")
1795 }
1796
1797 fn read_notes_head(store: &FileRefStore, notes_ref: &NotesRef) -> Option<ObjectId> {
1798 match store.read_ref(notes_ref.as_str()).expect("read ref") {
1799 Some(RefTarget::Direct(oid)) => Some(oid),
1800 _ => None,
1801 }
1802 }
1803
1804 fn install_fanout_note(
1805 git_dir: &Path,
1806 store: &FileRefStore,
1807 notes_ref: &NotesRef,
1808 annotated: &ObjectId,
1809 blob: ObjectId,
1810 identity: &NotesCommitIdentity,
1811 ) {
1812 let format = ObjectFormat::Sha1;
1813 let annotated_hex = annotated.to_hex();
1814 let mut db = FileObjectDatabase::from_git_dir(git_dir, format);
1815 let prefix = &annotated_hex[..2];
1816 let suffix = &annotated_hex[2..];
1817 let leaf = Tree {
1818 entries: vec![TreeEntry {
1819 mode: 0o100644,
1820 name: BString::from(suffix.as_bytes()),
1821 oid: blob,
1822 }],
1823 };
1824 let leaf_oid = db
1825 .write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
1826 .expect("leaf");
1827 let fanout = Tree {
1828 entries: vec![TreeEntry {
1829 mode: 0o040000,
1830 name: BString::from(prefix.as_bytes()),
1831 oid: leaf_oid,
1832 }],
1833 };
1834 let fanout_oid = db
1835 .write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
1836 .expect("fanout");
1837 let commit_oid = create_commit(
1838 &mut db,
1839 CommitCreate {
1840 tree: fanout_oid,
1841 parents: Vec::new(),
1842 author: identity.author.clone(),
1843 committer: identity.committer.clone(),
1844 message: b"fanout notes\n".to_vec(),
1845 encoding: None,
1846 signature: None,
1847 },
1848 )
1849 .expect("commit");
1850 let mut tx = store.transaction();
1851 tx.update(RefUpdate {
1852 name: notes_ref.as_str().to_string(),
1853 expected: notes_ref_expected(store, notes_ref).expect("ref expected"),
1854 new: RefTarget::Direct(commit_oid),
1855 reflog: None,
1856 });
1857 tx.commit().expect("update ref");
1858 }
1859
1860 #[test]
1861 fn upsert_note_for_unchanged_is_noop() {
1862 let dir = unique_temp_dir("upsert-unchanged");
1863 fs::create_dir_all(&dir).expect("create temp dir");
1864 let (git_dir, target) = init_repo_with_commit(&dir);
1865 let format = ObjectFormat::Sha1;
1866 let store = FileRefStore::new(&git_dir, format);
1867 let notes_ref = heddle_notes_ref();
1868 let identity = test_identity();
1869 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1870 let blob = write_blob(&mut db, br#"{"status":"served"}"#).expect("blob");
1871
1872 let first = upsert_note_for(
1873 &git_dir,
1874 format,
1875 &store,
1876 ¬es_ref,
1877 &target,
1878 blob.clone(),
1879 "heddle: export",
1880 &identity,
1881 None,
1882 )
1883 .expect("first upsert");
1884 let first_head = read_notes_head(&store, ¬es_ref).expect("head");
1885 assert!(matches!(first, UpsertNoteOutcome::Updated { .. }));
1886
1887 let second = upsert_note_for(
1888 &git_dir,
1889 format,
1890 &store,
1891 ¬es_ref,
1892 &target,
1893 blob,
1894 "heddle: export",
1895 &identity,
1896 Some(RefTarget::Direct(first_head)),
1897 )
1898 .expect("second upsert");
1899 assert_eq!(second, UpsertNoteOutcome::Unchanged);
1900 assert_eq!(read_notes_head(&store, ¬es_ref), Some(first_head));
1901 let _ = fs::remove_dir_all(&dir);
1902 }
1903
1904 #[test]
1905 fn upsert_note_for_updates_blob() {
1906 let dir = unique_temp_dir("upsert-update");
1907 fs::create_dir_all(&dir).expect("create temp dir");
1908 let (git_dir, target) = init_repo_with_commit(&dir);
1909 let format = ObjectFormat::Sha1;
1910 let store = FileRefStore::new(&git_dir, format);
1911 let notes_ref = heddle_notes_ref();
1912 let identity = test_identity();
1913 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1914 let blob_a = write_blob(&mut db, br#"{"v":1}"#).expect("blob a");
1915 let blob_b = write_blob(&mut db, br#"{"v":2}"#).expect("blob b");
1916
1917 let first = upsert_note_for(
1918 &git_dir,
1919 format,
1920 &store,
1921 ¬es_ref,
1922 &target,
1923 blob_a,
1924 "heddle: export",
1925 &identity,
1926 None,
1927 )
1928 .expect("first upsert");
1929 let UpsertNoteOutcome::Updated {
1930 notes_commit: first_commit,
1931 } = first
1932 else {
1933 panic!("expected first upsert to update");
1934 };
1935
1936 let second = upsert_note_for(
1937 &git_dir,
1938 format,
1939 &store,
1940 ¬es_ref,
1941 &target,
1942 blob_b,
1943 "heddle: export",
1944 &identity,
1945 Some(RefTarget::Direct(first_commit)),
1946 )
1947 .expect("second upsert");
1948 let UpsertNoteOutcome::Updated {
1949 notes_commit: second_commit,
1950 } = second
1951 else {
1952 panic!("expected second upsert to update");
1953 };
1954 assert_ne!(first_commit, second_commit);
1955 assert_eq!(
1956 read_note(&git_dir, format, &store, ¬es_ref, &target).expect("read"),
1957 Some(blob_b)
1958 );
1959 let _ = fs::remove_dir_all(&dir);
1960 }
1961
1962 #[test]
1963 fn upsert_note_for_creates_ref() {
1964 let dir = unique_temp_dir("upsert-create");
1965 fs::create_dir_all(&dir).expect("create temp dir");
1966 let (git_dir, target) = init_repo_with_commit(&dir);
1967 let format = ObjectFormat::Sha1;
1968 let store = FileRefStore::new(&git_dir, format);
1969 let notes_ref = heddle_notes_ref();
1970 let identity = test_identity();
1971 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1972 let blob = write_blob(&mut db, br#"{"status":"served"}"#).expect("blob");
1973
1974 assert_eq!(read_notes_head(&store, ¬es_ref), None);
1975 let outcome = upsert_note_for(
1976 &git_dir,
1977 format,
1978 &store,
1979 ¬es_ref,
1980 &target,
1981 blob,
1982 "heddle: export",
1983 &identity,
1984 None,
1985 )
1986 .expect("upsert");
1987 assert!(matches!(outcome, UpsertNoteOutcome::Updated { .. }));
1988 assert!(read_notes_head(&store, ¬es_ref).is_some());
1989 let _ = fs::remove_dir_all(&dir);
1990 }
1991
1992 #[test]
1993 fn upsert_note_for_cas_mismatch_fails() {
1994 let dir = unique_temp_dir("upsert-cas");
1995 fs::create_dir_all(&dir).expect("create temp dir");
1996 let (git_dir, target) = init_repo_with_commit(&dir);
1997 let format = ObjectFormat::Sha1;
1998 let store = FileRefStore::new(&git_dir, format);
1999 let notes_ref = heddle_notes_ref();
2000 let identity = test_identity();
2001 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2002 let blob_a = write_blob(&mut db, br#"{"v":1}"#).expect("blob a");
2003 let blob_b = write_blob(&mut db, br#"{"v":2}"#).expect("blob b");
2004
2005 upsert_note_for(
2006 &git_dir,
2007 format,
2008 &store,
2009 ¬es_ref,
2010 &target,
2011 blob_a,
2012 "heddle: export",
2013 &identity,
2014 None,
2015 )
2016 .expect("seed note");
2017 let head = read_notes_head(&store, ¬es_ref).expect("head");
2018 let wrong =
2019 ObjectId::from_hex(format, "cccccccccccccccccccccccccccccccccccccccc").expect("oid");
2020
2021 let err = upsert_note_for(
2022 &git_dir,
2023 format,
2024 &store,
2025 ¬es_ref,
2026 &target,
2027 blob_b,
2028 "heddle: export",
2029 &identity,
2030 Some(RefTarget::Direct(wrong)),
2031 )
2032 .expect_err("cas mismatch");
2033 assert!(matches!(err, GitError::Transaction(_)));
2034 assert_eq!(read_notes_head(&store, ¬es_ref), Some(head));
2035 let _ = fs::remove_dir_all(&dir);
2036 }
2037
2038 #[test]
2039 fn remove_notes_for_partial_hit() {
2040 let dir = unique_temp_dir("remove-partial");
2041 fs::create_dir_all(&dir).expect("create temp dir");
2042 let (git_dir, target) = init_repo_with_commit(&dir);
2043 let format = ObjectFormat::Sha1;
2044 let store = FileRefStore::new(&git_dir, format);
2045 let notes_ref = heddle_notes_ref();
2046 let identity = test_identity();
2047 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2048 let other =
2049 ObjectId::from_hex(format, "dddddddddddddddddddddddddddddddddddddddd").expect("oid");
2050 let blob_a = write_blob(&mut db, br#"{"a":1}"#).expect("blob a");
2051 let blob_b = write_blob(&mut db, br#"{"b":2}"#).expect("blob b");
2052
2053 write_notes(
2054 &git_dir,
2055 format,
2056 &store,
2057 ¬es_ref,
2058 &[
2059 Note {
2060 annotated: target,
2061 blob: blob_a,
2062 },
2063 Note {
2064 annotated: other,
2065 blob: blob_b,
2066 },
2067 ],
2068 "seed",
2069 &identity,
2070 None,
2071 )
2072 .expect("seed notes");
2073 let head = read_notes_head(&store, ¬es_ref).expect("head");
2074
2075 let outcome = remove_notes_for(
2076 &git_dir,
2077 format,
2078 &store,
2079 ¬es_ref,
2080 &[target],
2081 "heddle: retract",
2082 &identity,
2083 Some(RefTarget::Direct(head)),
2084 )
2085 .expect("remove");
2086 assert!(matches!(outcome, RemoveNoteOutcome::Removed { .. }));
2087 assert_eq!(
2088 read_note(&git_dir, format, &store, ¬es_ref, &target).expect("read"),
2089 None
2090 );
2091 assert_eq!(
2092 read_note(&git_dir, format, &store, ¬es_ref, &other).expect("read"),
2093 Some(blob_b)
2094 );
2095 let _ = fs::remove_dir_all(&dir);
2096 }
2097
2098 #[test]
2099 fn remove_notes_for_noop_when_missing() {
2100 let dir = unique_temp_dir("remove-noop");
2101 fs::create_dir_all(&dir).expect("create temp dir");
2102 let (git_dir, target) = init_repo_with_commit(&dir);
2103 let format = ObjectFormat::Sha1;
2104 let store = FileRefStore::new(&git_dir, format);
2105 let notes_ref = heddle_notes_ref();
2106 let identity = test_identity();
2107 let missing =
2108 ObjectId::from_hex(format, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee").expect("oid");
2109
2110 let absent = remove_notes_for(
2111 &git_dir,
2112 format,
2113 &store,
2114 ¬es_ref,
2115 &[target],
2116 "heddle: retract",
2117 &identity,
2118 None,
2119 )
2120 .expect("remove absent ref");
2121 assert_eq!(absent, RemoveNoteOutcome::Unchanged);
2122
2123 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2124 let blob = write_blob(&mut db, br#"{"x":1}"#).expect("blob");
2125 upsert_note_for(
2126 &git_dir,
2127 format,
2128 &store,
2129 ¬es_ref,
2130 &target,
2131 blob,
2132 "heddle: export",
2133 &identity,
2134 None,
2135 )
2136 .expect("seed");
2137 let head = read_notes_head(&store, ¬es_ref).expect("head");
2138
2139 let noop = remove_notes_for(
2140 &git_dir,
2141 format,
2142 &store,
2143 ¬es_ref,
2144 &[missing],
2145 "heddle: retract",
2146 &identity,
2147 Some(RefTarget::Direct(head)),
2148 )
2149 .expect("remove missing oid");
2150 assert_eq!(noop, RemoveNoteOutcome::Unchanged);
2151 assert_eq!(read_notes_head(&store, ¬es_ref), Some(head));
2152 let _ = fs::remove_dir_all(&dir);
2153 }
2154
2155 #[test]
2156 fn remove_notes_for_batch_single_commit() {
2157 let dir = unique_temp_dir("remove-batch");
2158 fs::create_dir_all(&dir).expect("create temp dir");
2159 let (git_dir, _) = init_repo_with_commit(&dir);
2160 let format = ObjectFormat::Sha1;
2161 let store = FileRefStore::new(&git_dir, format);
2162 let notes_ref = heddle_notes_ref();
2163 let identity = test_identity();
2164 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2165 let first =
2166 ObjectId::from_hex(format, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("oid");
2167 let second =
2168 ObjectId::from_hex(format, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").expect("oid");
2169 let third =
2170 ObjectId::from_hex(format, "cccccccccccccccccccccccccccccccccccccccc").expect("oid");
2171 let blob_a = write_blob(&mut db, b"a\n").expect("blob");
2172 let blob_b = write_blob(&mut db, b"b\n").expect("blob");
2173 let blob_c = write_blob(&mut db, b"c\n").expect("blob");
2174
2175 write_notes(
2176 &git_dir,
2177 format,
2178 &store,
2179 ¬es_ref,
2180 &[
2181 Note {
2182 annotated: first,
2183 blob: blob_a,
2184 },
2185 Note {
2186 annotated: second,
2187 blob: blob_b,
2188 },
2189 Note {
2190 annotated: third,
2191 blob: blob_c,
2192 },
2193 ],
2194 "seed",
2195 &identity,
2196 None,
2197 )
2198 .expect("seed");
2199 let head = read_notes_head(&store, ¬es_ref).expect("head");
2200
2201 let RemoveNoteOutcome::Removed { notes_commit } = remove_notes_for(
2202 &git_dir,
2203 format,
2204 &store,
2205 ¬es_ref,
2206 &[first, second],
2207 "heddle: retract",
2208 &identity,
2209 Some(RefTarget::Direct(head)),
2210 )
2211 .expect("batch remove") else {
2212 panic!("expected removal");
2213 };
2214
2215 let db = FileObjectDatabase::from_git_dir(&git_dir, format);
2216 let commit = db.read_object(¬es_commit).expect("read commit");
2217 let commit = Commit::parse_ref(format, &commit.body).expect("parse");
2218 assert_eq!(commit.parents.len(), 1);
2219 assert_eq!(commit.parents[0], head);
2220 assert_eq!(
2221 list_notes(&git_dir, format, &store, ¬es_ref)
2222 .expect("list")
2223 .len(),
2224 1
2225 );
2226 let _ = fs::remove_dir_all(&dir);
2227 }
2228
2229 #[test]
2230 fn incremental_ops_read_fanout_legacy() {
2231 let dir = unique_temp_dir("incremental-fanout");
2232 fs::create_dir_all(&dir).expect("create temp dir");
2233 let (git_dir, target) = init_repo_with_commit(&dir);
2234 let format = ObjectFormat::Sha1;
2235 let store = FileRefStore::new(&git_dir, format);
2236 let notes_ref = heddle_notes_ref();
2237 let identity = test_identity();
2238 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2239 let blob = write_blob(&mut db, br#"{"legacy":true}"#).expect("blob");
2240
2241 install_fanout_note(&git_dir, &store, ¬es_ref, &target, blob, &identity);
2242 let head = read_notes_head(&store, ¬es_ref).expect("head");
2243 let new_blob = write_blob(&mut db, br#"{"legacy":false}"#).expect("new blob");
2244
2245 upsert_note_for(
2246 &git_dir,
2247 format,
2248 &store,
2249 ¬es_ref,
2250 &target,
2251 new_blob,
2252 "heddle: export",
2253 &identity,
2254 Some(RefTarget::Direct(head)),
2255 )
2256 .expect("upsert fanout");
2257
2258 assert_eq!(
2259 read_note_for(&git_dir, format, &store, ¬es_ref, &target).expect("read"),
2260 Some(new_blob)
2261 );
2262 let _ = fs::remove_dir_all(&dir);
2263 }
2264
2265 #[test]
2266 fn incremental_ops_ff_chain() {
2267 let dir = unique_temp_dir("incremental-ff");
2268 fs::create_dir_all(&dir).expect("create temp dir");
2269 let (git_dir, target) = init_repo_with_commit(&dir);
2270 let format = ObjectFormat::Sha1;
2271 let store = FileRefStore::new(&git_dir, format);
2272 let notes_ref = heddle_notes_ref();
2273 let identity = test_identity();
2274 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2275 let other =
2276 ObjectId::from_hex(format, "ffffffffffffffffffffffffffffffffffffffff").expect("oid");
2277 let blob_a = write_blob(&mut db, br#"{"first":true}"#).expect("blob a");
2278 let blob_b = write_blob(&mut db, br#"{"second":true}"#).expect("blob b");
2279
2280 let UpsertNoteOutcome::Updated {
2281 notes_commit: first_commit,
2282 } = upsert_note_for(
2283 &git_dir,
2284 format,
2285 &store,
2286 ¬es_ref,
2287 &target,
2288 blob_a,
2289 "heddle: export",
2290 &identity,
2291 None,
2292 )
2293 .expect("first upsert")
2294 else {
2295 panic!("expected update");
2296 };
2297
2298 let UpsertNoteOutcome::Updated {
2299 notes_commit: second_commit,
2300 } = upsert_note_for(
2301 &git_dir,
2302 format,
2303 &store,
2304 ¬es_ref,
2305 &other,
2306 blob_b,
2307 "heddle: export",
2308 &identity,
2309 Some(RefTarget::Direct(first_commit)),
2310 )
2311 .expect("second upsert")
2312 else {
2313 panic!("expected update");
2314 };
2315
2316 let db = FileObjectDatabase::from_git_dir(&git_dir, format);
2317 let object = db.read_object(&second_commit).expect("read commit");
2318 let commit = Commit::parse_ref(format, &object.body).expect("parse");
2319 assert_eq!(commit.parents, vec![first_commit]);
2320 let _ = fs::remove_dir_all(&dir);
2321 }
2322}