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