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, HashSet};
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 = sley_rev::merge_bases(git_dir, format, &db, &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 notes_head_oid(store: &FileRefStore, notes_ref: &NotesRef) -> Result<Option<ObjectId>> {
841 Ok(match store.read_ref(notes_ref.as_str())? {
842 Some(RefTarget::Direct(oid)) => Some(oid),
843 _ => None,
844 })
845}
846
847fn notes_map_from_tree(
848 git_dir: &Path,
849 db: &FileObjectDatabase,
850 format: ObjectFormat,
851 tree_oid: ObjectId,
852) -> Result<BTreeMap<ObjectId, ObjectId>> {
853 let mut notes = BTreeMap::new();
854 if tree_oid == ObjectId::empty_tree(format) {
855 return Ok(notes);
856 }
857 collect_notes_from_tree(git_dir, db, format, tree_oid, "", &mut notes)?;
858 Ok(notes)
859}
860
861fn collect_notes_from_tree(
862 git_dir: &Path,
863 db: &FileObjectDatabase,
864 format: ObjectFormat,
865 tree_oid: ObjectId,
866 prefix: &str,
867 out: &mut BTreeMap<ObjectId, ObjectId>,
868) -> Result<()> {
869 for (name, mode, oid) in load_hex_tree_entries(db, format, &tree_oid)? {
870 let mut hex = prefix.to_string();
871 hex.push_str(&name);
872 if tree_entry_object_type(mode) == ObjectType::Tree {
873 collect_notes_from_tree(git_dir, db, format, oid, &hex, out)?;
874 } else if hex.len() == format.hex_len()
875 && let Ok(annotated) = ObjectId::from_hex(format, &hex)
876 {
877 let combined =
878 combine_loaded_note(git_dir, db, format, out.get(&annotated).copied(), oid)?;
879 match combined {
880 Some(blob) => {
881 out.insert(annotated, blob);
882 }
883 None => {
884 out.remove(&annotated);
885 }
886 }
887 }
888 }
889 Ok(())
890}
891
892fn combine_loaded_note(
893 git_dir: &Path,
894 db: &FileObjectDatabase,
895 format: ObjectFormat,
896 current: Option<ObjectId>,
897 next: ObjectId,
898) -> Result<Option<ObjectId>> {
899 if current.is_none() {
900 return Ok(Some(next));
901 }
902 if current == Some(next) {
903 return Ok(current);
904 }
905 combine_note_blobs(
906 git_dir,
907 db,
908 format,
909 current,
910 Some(next),
911 NoteBlobCombine::Union,
912 )
913}
914
915fn notes_vec_from_map(notes: BTreeMap<ObjectId, ObjectId>) -> Vec<Note> {
916 notes
917 .into_iter()
918 .map(|(annotated, blob)| Note { annotated, blob })
919 .collect()
920}
921
922fn set_note_option(
923 notes: &mut BTreeMap<ObjectId, ObjectId>,
924 annotated: ObjectId,
925 blob: Option<ObjectId>,
926) {
927 match blob {
928 Some(blob) => {
929 notes.insert(annotated, blob);
930 }
931 None => {
932 notes.remove(&annotated);
933 }
934 }
935}
936
937enum NoteBlobCombine {
938 Union,
939 CatSortUniq,
940}
941
942fn combine_note_blobs(
943 git_dir: &Path,
944 db: &FileObjectDatabase,
945 format: ObjectFormat,
946 local: Option<ObjectId>,
947 remote: Option<ObjectId>,
948 mode: NoteBlobCombine,
949) -> Result<Option<ObjectId>> {
950 match mode {
951 NoteBlobCombine::Union => combine_note_blobs_union(git_dir, db, format, local, remote),
952 NoteBlobCombine::CatSortUniq => {
953 combine_note_blobs_cat_sort_uniq(git_dir, db, format, local, remote)
954 }
955 }
956}
957
958fn read_blob_bytes(db: &FileObjectDatabase, oid: &ObjectId) -> Result<Option<Vec<u8>>> {
959 let object = db.read_object(oid)?;
960 if object.object_type != ObjectType::Blob || object.body.is_empty() {
961 return Ok(None);
962 }
963 Ok(Some(object.body.clone()))
964}
965
966fn combine_note_blobs_union(
967 git_dir: &Path,
968 db: &FileObjectDatabase,
969 format: ObjectFormat,
970 local: Option<ObjectId>,
971 remote: Option<ObjectId>,
972) -> Result<Option<ObjectId>> {
973 let Some(remote_oid) = remote else {
974 return Ok(local);
975 };
976 let Some(remote_body) = read_blob_bytes(db, &remote_oid)? else {
977 return Ok(local);
978 };
979 let Some(local_oid) = local else {
980 return Ok(Some(remote_oid));
981 };
982 let Some(mut local_body) = read_blob_bytes(db, &local_oid)? else {
983 return Ok(Some(remote_oid));
984 };
985 if local_body.last() == Some(&b'\n') {
986 local_body.pop();
987 }
988 local_body.extend_from_slice(b"\n\n");
989 local_body.extend_from_slice(&remote_body);
990 let writable = FileObjectDatabase::from_git_dir(git_dir, format);
991 writable
992 .write_object(EncodedObject::new(ObjectType::Blob, local_body))
993 .map(Some)
994}
995
996fn combine_note_blobs_cat_sort_uniq(
997 git_dir: &Path,
998 db: &FileObjectDatabase,
999 format: ObjectFormat,
1000 local: Option<ObjectId>,
1001 remote: Option<ObjectId>,
1002) -> Result<Option<ObjectId>> {
1003 let mut lines: Vec<Vec<u8>> = Vec::new();
1004 for oid in [local, remote].into_iter().flatten() {
1005 if let Some(body) = read_blob_bytes(db, &oid)? {
1006 lines.extend(body.split(|byte| *byte == b'\n').map(|line| line.to_vec()));
1007 }
1008 }
1009 lines.retain(|line| !line.is_empty());
1010 if lines.is_empty() {
1011 return Ok(None);
1012 }
1013 lines.sort();
1014 lines.dedup();
1015 let mut body = Vec::new();
1016 for line in lines {
1017 body.extend_from_slice(&line);
1018 body.push(b'\n');
1019 }
1020 let writable = FileObjectDatabase::from_git_dir(git_dir, format);
1021 writable
1022 .write_object(EncodedObject::new(ObjectType::Blob, body))
1023 .map(Some)
1024}
1025
1026#[allow(clippy::too_many_arguments)]
1027fn commit_notes_update(
1028 git_dir: &Path,
1029 format: ObjectFormat,
1030 store: &FileRefStore,
1031 notes_ref: &NotesRef,
1032 notes: &[Note],
1033 message: &str,
1034 identity: &NotesCommitIdentity,
1035 ref_expected: Option<RefTarget>,
1036) -> Result<ObjectId> {
1037 let parent = notes_head_oid(store, notes_ref)?;
1038 let parents = parent.iter().cloned().collect::<Vec<_>>();
1039 commit_notes_update_with_parents(
1040 git_dir,
1041 format,
1042 store,
1043 notes_ref,
1044 notes,
1045 format!("{message}\n").as_bytes(),
1046 identity,
1047 &parents,
1048 ref_expected,
1049 true,
1050 )
1051}
1052
1053#[allow(clippy::too_many_arguments)]
1054fn commit_notes_update_with_parents(
1055 git_dir: &Path,
1056 format: ObjectFormat,
1057 store: &FileRefStore,
1058 notes_ref: &NotesRef,
1059 notes: &[Note],
1060 message: &[u8],
1061 identity: &NotesCommitIdentity,
1062 parents: &[ObjectId],
1063 ref_expected: Option<RefTarget>,
1064 update_ref: bool,
1065) -> Result<ObjectId> {
1066 let mut db = FileObjectDatabase::from_git_dir(git_dir, format);
1067 let non_notes = match parents.first() {
1073 Some(parent) => collect_non_notes_from_commit(&db, format, parent)?,
1074 None => Vec::new(),
1075 };
1076 let tree_oid = write_notes_tree_preserving(&mut db, notes, &non_notes)?;
1077
1078 let commit_oid = create_commit(
1079 &mut db,
1080 CommitCreate {
1081 tree: tree_oid,
1082 parents: parents.to_vec(),
1083 author: identity.author.clone(),
1084 committer: identity.committer.clone(),
1085 message: message.to_vec(),
1086 encoding: None,
1087 signature: None,
1088 },
1089 )?;
1090
1091 if !update_ref {
1092 return Ok(commit_oid);
1093 }
1094 let old_oid = parents.first().copied().unwrap_or(zero_oid(format)?);
1095 let mut tx = store.transaction();
1096 let reflog_message = reflog_message_from_commit_message(message);
1097 tx.update(RefUpdate {
1098 name: notes_ref.as_str().to_string(),
1099 expected: ref_expected,
1100 new: RefTarget::Direct(commit_oid),
1101 reflog: Some(ReflogEntry {
1102 old_oid,
1103 new_oid: commit_oid,
1104 committer: identity.committer.clone(),
1105 message: reflog_message,
1108 }),
1109 });
1110 tx.commit()?;
1111 Ok(commit_oid)
1112}
1113
1114fn update_notes_ref_to_commit(
1115 git_dir: &Path,
1116 format: ObjectFormat,
1117 store: &FileRefStore,
1118 notes_ref: &NotesRef,
1119 old: Option<ObjectId>,
1120 new: ObjectId,
1121 message: &str,
1122 identity: &NotesCommitIdentity,
1123) -> Result<()> {
1124 let old_oid = old.unwrap_or(zero_oid(format)?);
1125 let mut tx = store.transaction();
1126 tx.update(RefUpdate {
1127 name: notes_ref.as_str().to_string(),
1128 expected: old.map(RefTarget::Direct),
1129 new: RefTarget::Direct(new),
1130 reflog: Some(ReflogEntry {
1131 old_oid,
1132 new_oid: new,
1133 committer: identity.committer.clone(),
1134 message: format!("notes: {message}").into_bytes(),
1135 }),
1136 });
1137 let _ = git_dir;
1138 tx.commit()
1139}
1140
1141fn reflog_message_from_commit_message(message: &[u8]) -> Vec<u8> {
1142 let subject = message
1143 .split(|byte| *byte == b'\n')
1144 .next()
1145 .unwrap_or(message);
1146 let mut out = b"notes: ".to_vec();
1147 out.extend_from_slice(subject);
1148 out
1149}
1150
1151fn write_notes_tree(db: &mut FileObjectDatabase, notes: &[Note]) -> Result<ObjectId> {
1152 if notes.len() >= 256 {
1153 write_fanout_notes_tree(db, notes)
1154 } else {
1155 write_flat_notes_tree(db, notes)
1156 }
1157}
1158
1159fn write_flat_notes_tree(db: &mut FileObjectDatabase, notes: &[Note]) -> Result<ObjectId> {
1160 let mut entries: Vec<TreeEntry> = notes
1161 .iter()
1162 .map(|note| TreeEntry {
1163 mode: 0o100644,
1164 name: BString::from(note.annotated.to_hex().as_bytes()),
1165 oid: note.blob,
1166 })
1167 .collect();
1168 entries.sort_by(|left, right| left.name.cmp(&right.name));
1169 db.write_object(EncodedObject::new(
1170 ObjectType::Tree,
1171 Tree { entries }.write(),
1172 ))
1173}
1174
1175fn write_fanout_notes_tree(db: &mut FileObjectDatabase, notes: &[Note]) -> Result<ObjectId> {
1176 let mut groups: BTreeMap<String, Vec<TreeEntry>> = BTreeMap::new();
1177 for note in notes {
1178 let hex = note.annotated.to_hex();
1179 let (prefix, suffix) = hex.split_at(2);
1180 groups
1181 .entry(prefix.to_string())
1182 .or_default()
1183 .push(TreeEntry {
1184 mode: 0o100644,
1185 name: BString::from(suffix.as_bytes()),
1186 oid: note.blob,
1187 });
1188 }
1189
1190 let mut root_entries = Vec::new();
1191 for (prefix, mut entries) in groups {
1192 entries.sort_by(|left, right| left.name.cmp(&right.name));
1193 let subtree_oid = db.write_object(EncodedObject::new(
1194 ObjectType::Tree,
1195 Tree { entries }.write(),
1196 ))?;
1197 root_entries.push(TreeEntry {
1198 mode: 0o040000,
1199 name: BString::from(prefix.as_bytes()),
1200 oid: subtree_oid,
1201 });
1202 }
1203 root_entries.sort_by(|left, right| left.name.cmp(&right.name));
1204 db.write_object(EncodedObject::new(
1205 ObjectType::Tree,
1206 Tree {
1207 entries: root_entries,
1208 }
1209 .write(),
1210 ))
1211}
1212
1213type PathEntry = (Vec<u8>, u32, ObjectId);
1215
1216#[derive(Debug, Clone)]
1221struct NonNote {
1222 path: Vec<u8>,
1224 mode: u32,
1225 oid: ObjectId,
1226}
1227
1228fn collect_non_notes_from_commit(
1234 db: &FileObjectDatabase,
1235 format: ObjectFormat,
1236 commit_oid: &ObjectId,
1237) -> Result<Vec<NonNote>> {
1238 let object = db.read_object(commit_oid)?;
1239 if object.object_type != ObjectType::Commit {
1240 return Ok(Vec::new());
1241 }
1242 let commit = Commit::parse(format, &object.body)?;
1243 let mut out = Vec::new();
1244 collect_non_notes_rec(db, format, &commit.tree, &[], 0, &mut out)?;
1245 Ok(out)
1246}
1247
1248fn collect_non_notes_rec(
1249 db: &FileObjectDatabase,
1250 format: ObjectFormat,
1251 tree_oid: &ObjectId,
1252 prefix: &[u8],
1253 consumed_hex: usize,
1254 out: &mut Vec<NonNote>,
1255) -> Result<()> {
1256 let object = db.read_object(tree_oid)?;
1257 if object.object_type != ObjectType::Tree {
1258 return Ok(());
1259 }
1260 let hex_len = format.hex_len();
1261 for entry in TreeEntries::new(format, &object.body) {
1262 let entry = entry?;
1263 let name = entry.name;
1264 let name_len = name.len();
1265 let is_tree = tree_entry_object_type(entry.mode) == ObjectType::Tree;
1266 let is_hex = !name.is_empty() && name.iter().all(u8::is_ascii_hexdigit);
1267
1268 if consumed_hex < hex_len && name_len == hex_len - consumed_hex {
1269 if !is_tree && is_hex {
1271 continue;
1272 }
1273 } else if name_len == 2 && is_tree && is_hex {
1274 let mut child_prefix = prefix.to_vec();
1276 child_prefix.extend_from_slice(name);
1277 child_prefix.push(b'/');
1278 collect_non_notes_rec(db, format, &entry.oid, &child_prefix, consumed_hex + 2, out)?;
1279 continue;
1280 }
1281
1282 let mut full = prefix.to_vec();
1284 full.extend_from_slice(name);
1285 out.push(NonNote {
1286 path: full,
1287 mode: entry.mode,
1288 oid: entry.oid,
1289 });
1290 }
1291 Ok(())
1292}
1293
1294fn write_notes_tree_preserving(
1298 db: &mut FileObjectDatabase,
1299 notes: &[Note],
1300 non_notes: &[NonNote],
1301) -> Result<ObjectId> {
1302 if non_notes.is_empty() {
1303 return write_notes_tree(db, notes);
1304 }
1305 write_woven_notes_tree(db, notes, non_notes)
1306}
1307
1308fn note_disk_paths(notes: &[Note]) -> Vec<PathEntry> {
1311 if notes.len() >= 256 {
1312 notes
1313 .iter()
1314 .map(|note| {
1315 let hex = note.annotated.to_hex();
1316 let (prefix, suffix) = hex.split_at(2);
1317 let mut path = prefix.as_bytes().to_vec();
1318 path.push(b'/');
1319 path.extend_from_slice(suffix.as_bytes());
1320 (path, 0o100644u32, note.blob)
1321 })
1322 .collect()
1323 } else {
1324 notes
1325 .iter()
1326 .map(|note| (note.annotated.to_hex().into_bytes(), 0o100644u32, note.blob))
1327 .collect()
1328 }
1329}
1330
1331fn write_woven_notes_tree(
1332 db: &mut FileObjectDatabase,
1333 notes: &[Note],
1334 non_notes: &[NonNote],
1335) -> Result<ObjectId> {
1336 let mut paths: BTreeMap<Vec<u8>, (u32, ObjectId)> = BTreeMap::new();
1339 for (path, mode, oid) in note_disk_paths(notes) {
1340 paths.insert(path, (mode, oid));
1341 }
1342 for non_note in non_notes {
1343 paths
1344 .entry(non_note.path.clone())
1345 .or_insert((non_note.mode, non_note.oid));
1346 }
1347 let entries: Vec<PathEntry> = paths
1348 .into_iter()
1349 .map(|(path, (mode, oid))| (path, mode, oid))
1350 .collect();
1351 build_nested_tree(db, &entries)
1352}
1353
1354fn build_nested_tree(db: &mut FileObjectDatabase, entries: &[PathEntry]) -> Result<ObjectId> {
1358 let mut builder = TreeBuilder::new();
1359 let mut subdirs: BTreeMap<Vec<u8>, Vec<PathEntry>> = BTreeMap::new();
1360 for (path, mode, oid) in entries {
1361 match path.iter().position(|byte| *byte == b'/') {
1362 None => builder.upsert_raw(path.clone(), *mode, *oid),
1363 Some(slash) => {
1364 let component = path[..slash].to_vec();
1365 let rest = path[slash + 1..].to_vec();
1366 subdirs
1367 .entry(component)
1368 .or_default()
1369 .push((rest, *mode, *oid));
1370 }
1371 }
1372 }
1373 for (component, children) in subdirs {
1374 let subtree_oid = build_nested_tree(db, &children)?;
1375 builder.upsert_raw(component, 0o040000, subtree_oid);
1376 }
1377 db.write_object(EncodedObject::new(
1378 ObjectType::Tree,
1379 builder.build().write(),
1380 ))
1381}
1382
1383fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
1384 ObjectId::from_hex(format, &"0".repeat(format.hex_len()))
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389 use super::*;
1390 use sley_sequencer::format_commit_identity;
1391 use std::fs;
1392 use std::path::{Path, PathBuf};
1393 use std::process::{Command, Stdio};
1394 use std::time::{SystemTime, UNIX_EPOCH};
1395
1396 const NAME: &str = "Tester";
1397 const EMAIL: &str = "tester@example.com";
1398 const DATE: &str = "@1790000000 -0500";
1399
1400 fn unique_temp_dir(name: &str) -> PathBuf {
1401 let nanos = SystemTime::now()
1402 .duration_since(UNIX_EPOCH)
1403 .expect("system time before unix epoch")
1404 .as_nanos();
1405 std::env::temp_dir().join(format!("sley-notes-{name}-{}-{nanos}", std::process::id()))
1406 }
1407
1408 fn git_available() -> bool {
1409 Command::new("git")
1410 .arg("--version")
1411 .stdout(Stdio::null())
1412 .stderr(Stdio::null())
1413 .status()
1414 .map(|status| status.success())
1415 .unwrap_or(false)
1416 }
1417
1418 fn test_identity() -> NotesCommitIdentity {
1419 NotesCommitIdentity {
1420 author: format_commit_identity(NAME, EMAIL, DATE)
1421 .expect("test operation should succeed"),
1422 committer: format_commit_identity(NAME, EMAIL, DATE)
1423 .expect("test operation should succeed"),
1424 }
1425 }
1426
1427 fn git_env(command: &mut Command) -> &mut Command {
1428 command
1429 .env("GIT_AUTHOR_NAME", NAME)
1430 .env("GIT_AUTHOR_EMAIL", EMAIL)
1431 .env("GIT_AUTHOR_DATE", DATE)
1432 .env("GIT_COMMITTER_NAME", NAME)
1433 .env("GIT_COMMITTER_EMAIL", EMAIL)
1434 .env("GIT_COMMITTER_DATE", DATE)
1435 }
1436
1437 fn init_repo_with_commit(root: &Path) -> (PathBuf, ObjectId) {
1438 let mut init = Command::new("git");
1439 git_env(init.current_dir(root).args(["init", "-q"]))
1440 .status()
1441 .expect("git init should succeed");
1442 fs::write(root.join("f.txt"), b"content\n").expect("write worktree file");
1443 let mut add = Command::new("git");
1444 git_env(add.current_dir(root).args(["add", "f.txt"]))
1445 .status()
1446 .expect("git add should succeed");
1447 let mut commit = Command::new("git");
1448 git_env(commit.current_dir(root).args(["commit", "-q", "-m", "c1"]))
1449 .status()
1450 .expect("git commit should succeed");
1451 let git_dir = root.join(".git");
1452 let format = ObjectFormat::Sha1;
1453 let store = FileRefStore::new(&git_dir, format);
1454 let head = store
1455 .read_ref("HEAD")
1456 .expect("read HEAD")
1457 .expect("HEAD should exist");
1458 let oid = match head {
1459 RefTarget::Direct(oid) => oid,
1460 RefTarget::Symbolic(name) => match store.read_ref(&name).expect("read symref") {
1461 Some(RefTarget::Direct(oid)) => oid,
1462 other => panic!("unexpected symref target: {other:?}"),
1463 },
1464 };
1465 (git_dir, oid)
1466 }
1467
1468 fn write_blob(db: &mut FileObjectDatabase, bytes: &[u8]) -> Result<ObjectId> {
1469 db.write_object(EncodedObject::new(ObjectType::Blob, bytes.to_vec()))
1470 }
1471
1472 #[test]
1473 fn notes_ref_expand_qualifies_names() {
1474 assert_eq!(NotesRef::expand("commits").as_str(), "refs/notes/commits");
1475 assert_eq!(
1476 NotesRef::expand("refs/notes/review").as_str(),
1477 "refs/notes/review"
1478 );
1479 }
1480
1481 #[test]
1482 fn read_write_list_round_trip() {
1483 let dir = unique_temp_dir("round-trip");
1484 fs::create_dir_all(&dir).expect("create temp dir");
1485 let (git_dir, target) = init_repo_with_commit(&dir);
1486 let format = ObjectFormat::Sha1;
1487 let store = FileRefStore::new(&git_dir, format);
1488 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1489 let identity = test_identity();
1490 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1491 let blob = write_blob(&mut db, b"hello note\n").expect("test operation should succeed");
1492
1493 let mut notes = Vec::new();
1494 upsert_note(&mut notes, &target, blob);
1495 write_notes(
1496 &git_dir,
1497 format,
1498 &store,
1499 ¬es_ref,
1500 ¬es,
1501 "Notes added by test",
1502 &identity,
1503 notes_ref_expected(&store, ¬es_ref).expect("ref expected"),
1504 )
1505 .expect("test operation should succeed");
1506
1507 let listed = list_notes(&git_dir, format, &store, ¬es_ref)
1508 .expect("test operation should succeed");
1509 assert_eq!(listed.len(), 1);
1510 assert_eq!(listed[0].annotated, target);
1511 assert_eq!(listed[0].blob, blob);
1512
1513 let read_back = read_note(&git_dir, format, &store, ¬es_ref, &target)
1514 .expect("test operation should succeed");
1515 assert_eq!(read_back, Some(blob));
1516
1517 let bytes = read_note_bytes(&git_dir, format, &store, ¬es_ref, &target)
1518 .expect("test operation should succeed");
1519 assert_eq!(bytes.as_deref(), Some(b"hello note\n" as &[u8]));
1520 let _ = fs::remove_dir_all(&dir);
1521 }
1522
1523 #[test]
1524 fn iter_notes_matches_list_notes() {
1525 let dir = unique_temp_dir("iter-list");
1526 fs::create_dir_all(&dir).expect("create temp dir");
1527 let (git_dir, target) = init_repo_with_commit(&dir);
1528 let format = ObjectFormat::Sha1;
1529 let store = FileRefStore::new(&git_dir, format);
1530 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1531 let blob = write_blob(&mut db, b"iter note\n").expect("blob");
1532 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1533 write_notes(
1534 &git_dir,
1535 format,
1536 &store,
1537 ¬es_ref,
1538 &[Note {
1539 annotated: target,
1540 blob,
1541 }],
1542 "note",
1543 &test_identity(),
1544 notes_ref_expected(&store, ¬es_ref).expect("ref expected"),
1545 )
1546 .expect("write notes");
1547
1548 let listed = list_notes(&git_dir, format, &store, ¬es_ref).expect("list");
1549 let mut iter_collected = iter_notes(&git_dir, format, &store, ¬es_ref)
1550 .expect("iter")
1551 .collect::<Result<Vec<_>>>()
1552 .expect("collect");
1553 iter_collected.sort_by_key(|entry| entry.annotated.to_hex());
1554 assert_eq!(listed, iter_collected);
1555 let _ = fs::remove_dir_all(&dir);
1556 }
1557
1558 #[test]
1559 fn iter_notes_yields_every_note_in_flat_tree() {
1560 let dir = unique_temp_dir("iter-flat-multi");
1561 fs::create_dir_all(&dir).expect("create temp dir");
1562 let (git_dir, _) = init_repo_with_commit(&dir);
1563 let format = ObjectFormat::Sha1;
1564 let store = FileRefStore::new(&git_dir, format);
1565 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1566 let first =
1567 ObjectId::from_hex(format, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("oid");
1568 let second =
1569 ObjectId::from_hex(format, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").expect("oid");
1570 let blob_a = write_blob(&mut db, b"note a\n").expect("blob");
1571 let blob_b = write_blob(&mut db, b"note b\n").expect("blob");
1572 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1573 write_notes(
1574 &git_dir,
1575 format,
1576 &store,
1577 ¬es_ref,
1578 &[
1579 Note {
1580 annotated: first,
1581 blob: blob_a,
1582 },
1583 Note {
1584 annotated: second,
1585 blob: blob_b,
1586 },
1587 ],
1588 "notes",
1589 &test_identity(),
1590 notes_ref_expected(&store, ¬es_ref).expect("ref expected"),
1591 )
1592 .expect("write notes");
1593
1594 let collected = iter_notes(&git_dir, format, &store, ¬es_ref)
1595 .expect("iter")
1596 .collect::<Result<Vec<_>>>()
1597 .expect("collect");
1598 assert_eq!(collected.len(), 2);
1599 let _ = fs::remove_dir_all(&dir);
1600 }
1601
1602 #[test]
1603 fn read_note_for_skips_unrelated_fanout_branches() {
1604 let dir = unique_temp_dir("lookup");
1605 fs::create_dir_all(&dir).expect("create temp dir");
1606 let (git_dir, target) = init_repo_with_commit(&dir);
1607 let format = ObjectFormat::Sha1;
1608 let store = FileRefStore::new(&git_dir, format);
1609 let target_hex = target.to_hex();
1610 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1611 let blob = write_blob(&mut db, b"lookup note\n").expect("blob");
1612 let prefix = &target_hex[..2];
1613 let suffix = &target_hex[2..];
1614 let leaf = Tree {
1615 entries: vec![TreeEntry {
1616 mode: 0o100644,
1617 name: BString::from(suffix.as_bytes()),
1618 oid: blob,
1619 }],
1620 };
1621 let leaf_oid = db
1622 .write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
1623 .expect("leaf");
1624 let fanout = Tree {
1625 entries: vec![TreeEntry {
1626 mode: 0o040000,
1627 name: BString::from(prefix.as_bytes()),
1628 oid: leaf_oid,
1629 }],
1630 };
1631 let fanout_oid = db
1632 .write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
1633 .expect("fanout");
1634 let identity = test_identity();
1635 let commit_oid = create_commit(
1636 &mut db,
1637 CommitCreate {
1638 tree: fanout_oid,
1639 parents: Vec::new(),
1640 author: identity.author.clone(),
1641 committer: identity.committer.clone(),
1642 message: b"fanout notes\n".to_vec(),
1643 encoding: None,
1644 signature: None,
1645 },
1646 )
1647 .expect("commit");
1648 let mut tx = store.transaction();
1649 tx.update(RefUpdate {
1650 name: DEFAULT_NOTES_REF.to_string(),
1651 expected: None,
1652 new: RefTarget::Direct(commit_oid),
1653 reflog: None,
1654 });
1655 tx.commit().expect("update ref");
1656 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1657 let found = read_note_for(&git_dir, format, &store, ¬es_ref, &target).expect("lookup");
1658 assert_eq!(found, Some(blob));
1659 let _ = fs::remove_dir_all(&dir);
1660 }
1661
1662 #[test]
1663 fn fanout_tree_is_readable() {
1664 let dir = unique_temp_dir("fanout");
1665 fs::create_dir_all(&dir).expect("create temp dir");
1666 let (git_dir, target) = init_repo_with_commit(&dir);
1667 let format = ObjectFormat::Sha1;
1668 let store = FileRefStore::new(&git_dir, format);
1669 let target_hex = target.to_hex();
1670 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1671 let blob = write_blob(&mut db, b"fanout note\n").expect("test operation should succeed");
1672
1673 let prefix = &target_hex[..2];
1675 let suffix = &target_hex[2..];
1676 let leaf = Tree {
1677 entries: vec![TreeEntry {
1678 mode: 0o100644,
1679 name: BString::from(suffix.as_bytes()),
1680 oid: blob,
1681 }],
1682 };
1683 let leaf_oid = db
1684 .write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
1685 .expect("test operation should succeed");
1686 let fanout = Tree {
1687 entries: vec![TreeEntry {
1688 mode: 0o040000,
1689 name: BString::from(prefix.as_bytes()),
1690 oid: leaf_oid,
1691 }],
1692 };
1693 let fanout_oid = db
1694 .write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
1695 .expect("test operation should succeed");
1696
1697 let identity = test_identity();
1698 let commit_oid = create_commit(
1699 &mut db,
1700 CommitCreate {
1701 tree: fanout_oid,
1702 parents: Vec::new(),
1703 author: identity.author.clone(),
1704 committer: identity.committer.clone(),
1705 message: b"fanout notes\n".to_vec(),
1706 encoding: None,
1707 signature: None,
1708 },
1709 )
1710 .expect("test operation should succeed");
1711 let mut tx = store.transaction();
1712 tx.update(RefUpdate {
1713 name: DEFAULT_NOTES_REF.to_string(),
1714 expected: None,
1715 new: RefTarget::Direct(commit_oid),
1716 reflog: None,
1717 });
1718 tx.commit().expect("test operation should succeed");
1719
1720 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1721 let read_back = read_note(&git_dir, format, &store, ¬es_ref, &target)
1722 .expect("test operation should succeed");
1723 assert_eq!(read_back, Some(blob));
1724 let _ = fs::remove_dir_all(&dir);
1725 }
1726
1727 #[test]
1728 fn note_bytes_match_system_git() {
1729 if !git_available() {
1730 return;
1731 }
1732 let dir = unique_temp_dir("git-interop");
1733 fs::create_dir_all(&dir).expect("test operation should succeed");
1734 let result = std::panic::catch_unwind(|| {
1735 let (git_dir, target) = init_repo_with_commit(&dir);
1736 let format = ObjectFormat::Sha1;
1737 let store = FileRefStore::new(&git_dir, format);
1738 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1739
1740 let mut git_add_cmd = Command::new("git");
1741 let git_add = git_env(git_add_cmd.current_dir(&dir).args([
1742 "notes",
1743 "add",
1744 "-m",
1745 "interop note",
1746 "HEAD",
1747 ]))
1748 .output()
1749 .expect("git notes add should run");
1750 assert!(
1751 git_add.status.success(),
1752 "git notes add failed: {}",
1753 String::from_utf8_lossy(&git_add.stderr)
1754 );
1755
1756 let sley_bytes = read_note_bytes(&git_dir, format, &store, ¬es_ref, &target)
1757 .expect("test operation should succeed")
1758 .expect("note should exist");
1759
1760 let mut git_show_cmd = Command::new("git");
1761 let git_output = git_env(
1762 git_show_cmd
1763 .current_dir(&dir)
1764 .args(["notes", "show", "HEAD"]),
1765 )
1766 .output()
1767 .expect("test operation should succeed");
1768 assert!(
1769 git_output.status.success(),
1770 "git notes show failed: {}",
1771 String::from_utf8_lossy(&git_output.stderr)
1772 );
1773 assert_eq!(sley_bytes, git_output.stdout);
1774 });
1775 let _ = fs::remove_dir_all(&dir);
1776 result.expect("note_bytes_match_system_git assertions");
1777 }
1778
1779 fn heddle_notes_ref() -> NotesRef {
1780 NotesRef::expand("refs/notes/heddle")
1781 }
1782
1783 fn read_notes_head(store: &FileRefStore, notes_ref: &NotesRef) -> Option<ObjectId> {
1784 match store.read_ref(notes_ref.as_str()).expect("read ref") {
1785 Some(RefTarget::Direct(oid)) => Some(oid),
1786 _ => None,
1787 }
1788 }
1789
1790 fn install_fanout_note(
1791 git_dir: &Path,
1792 store: &FileRefStore,
1793 notes_ref: &NotesRef,
1794 annotated: &ObjectId,
1795 blob: ObjectId,
1796 identity: &NotesCommitIdentity,
1797 ) {
1798 let format = ObjectFormat::Sha1;
1799 let annotated_hex = annotated.to_hex();
1800 let mut db = FileObjectDatabase::from_git_dir(git_dir, format);
1801 let prefix = &annotated_hex[..2];
1802 let suffix = &annotated_hex[2..];
1803 let leaf = Tree {
1804 entries: vec![TreeEntry {
1805 mode: 0o100644,
1806 name: BString::from(suffix.as_bytes()),
1807 oid: blob,
1808 }],
1809 };
1810 let leaf_oid = db
1811 .write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
1812 .expect("leaf");
1813 let fanout = Tree {
1814 entries: vec![TreeEntry {
1815 mode: 0o040000,
1816 name: BString::from(prefix.as_bytes()),
1817 oid: leaf_oid,
1818 }],
1819 };
1820 let fanout_oid = db
1821 .write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
1822 .expect("fanout");
1823 let commit_oid = create_commit(
1824 &mut db,
1825 CommitCreate {
1826 tree: fanout_oid,
1827 parents: Vec::new(),
1828 author: identity.author.clone(),
1829 committer: identity.committer.clone(),
1830 message: b"fanout notes\n".to_vec(),
1831 encoding: None,
1832 signature: None,
1833 },
1834 )
1835 .expect("commit");
1836 let mut tx = store.transaction();
1837 tx.update(RefUpdate {
1838 name: notes_ref.as_str().to_string(),
1839 expected: notes_ref_expected(store, notes_ref).expect("ref expected"),
1840 new: RefTarget::Direct(commit_oid),
1841 reflog: None,
1842 });
1843 tx.commit().expect("update ref");
1844 }
1845
1846 #[test]
1847 fn upsert_note_for_unchanged_is_noop() {
1848 let dir = unique_temp_dir("upsert-unchanged");
1849 fs::create_dir_all(&dir).expect("create temp dir");
1850 let (git_dir, target) = init_repo_with_commit(&dir);
1851 let format = ObjectFormat::Sha1;
1852 let store = FileRefStore::new(&git_dir, format);
1853 let notes_ref = heddle_notes_ref();
1854 let identity = test_identity();
1855 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1856 let blob = write_blob(&mut db, br#"{"status":"served"}"#).expect("blob");
1857
1858 let first = upsert_note_for(
1859 &git_dir,
1860 format,
1861 &store,
1862 ¬es_ref,
1863 &target,
1864 blob.clone(),
1865 "heddle: export",
1866 &identity,
1867 None,
1868 )
1869 .expect("first upsert");
1870 let first_head = read_notes_head(&store, ¬es_ref).expect("head");
1871 assert!(matches!(first, UpsertNoteOutcome::Updated { .. }));
1872
1873 let second = upsert_note_for(
1874 &git_dir,
1875 format,
1876 &store,
1877 ¬es_ref,
1878 &target,
1879 blob,
1880 "heddle: export",
1881 &identity,
1882 Some(RefTarget::Direct(first_head)),
1883 )
1884 .expect("second upsert");
1885 assert_eq!(second, UpsertNoteOutcome::Unchanged);
1886 assert_eq!(read_notes_head(&store, ¬es_ref), Some(first_head));
1887 let _ = fs::remove_dir_all(&dir);
1888 }
1889
1890 #[test]
1891 fn upsert_note_for_updates_blob() {
1892 let dir = unique_temp_dir("upsert-update");
1893 fs::create_dir_all(&dir).expect("create temp dir");
1894 let (git_dir, target) = init_repo_with_commit(&dir);
1895 let format = ObjectFormat::Sha1;
1896 let store = FileRefStore::new(&git_dir, format);
1897 let notes_ref = heddle_notes_ref();
1898 let identity = test_identity();
1899 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1900 let blob_a = write_blob(&mut db, br#"{"v":1}"#).expect("blob a");
1901 let blob_b = write_blob(&mut db, br#"{"v":2}"#).expect("blob b");
1902
1903 let first = upsert_note_for(
1904 &git_dir,
1905 format,
1906 &store,
1907 ¬es_ref,
1908 &target,
1909 blob_a,
1910 "heddle: export",
1911 &identity,
1912 None,
1913 )
1914 .expect("first upsert");
1915 let UpsertNoteOutcome::Updated {
1916 notes_commit: first_commit,
1917 } = first
1918 else {
1919 panic!("expected first upsert to update");
1920 };
1921
1922 let second = upsert_note_for(
1923 &git_dir,
1924 format,
1925 &store,
1926 ¬es_ref,
1927 &target,
1928 blob_b,
1929 "heddle: export",
1930 &identity,
1931 Some(RefTarget::Direct(first_commit)),
1932 )
1933 .expect("second upsert");
1934 let UpsertNoteOutcome::Updated {
1935 notes_commit: second_commit,
1936 } = second
1937 else {
1938 panic!("expected second upsert to update");
1939 };
1940 assert_ne!(first_commit, second_commit);
1941 assert_eq!(
1942 read_note(&git_dir, format, &store, ¬es_ref, &target).expect("read"),
1943 Some(blob_b)
1944 );
1945 let _ = fs::remove_dir_all(&dir);
1946 }
1947
1948 #[test]
1949 fn upsert_note_for_creates_ref() {
1950 let dir = unique_temp_dir("upsert-create");
1951 fs::create_dir_all(&dir).expect("create temp dir");
1952 let (git_dir, target) = init_repo_with_commit(&dir);
1953 let format = ObjectFormat::Sha1;
1954 let store = FileRefStore::new(&git_dir, format);
1955 let notes_ref = heddle_notes_ref();
1956 let identity = test_identity();
1957 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1958 let blob = write_blob(&mut db, br#"{"status":"served"}"#).expect("blob");
1959
1960 assert_eq!(read_notes_head(&store, ¬es_ref), None);
1961 let outcome = upsert_note_for(
1962 &git_dir,
1963 format,
1964 &store,
1965 ¬es_ref,
1966 &target,
1967 blob,
1968 "heddle: export",
1969 &identity,
1970 None,
1971 )
1972 .expect("upsert");
1973 assert!(matches!(outcome, UpsertNoteOutcome::Updated { .. }));
1974 assert!(read_notes_head(&store, ¬es_ref).is_some());
1975 let _ = fs::remove_dir_all(&dir);
1976 }
1977
1978 #[test]
1979 fn upsert_note_for_cas_mismatch_fails() {
1980 let dir = unique_temp_dir("upsert-cas");
1981 fs::create_dir_all(&dir).expect("create temp dir");
1982 let (git_dir, target) = init_repo_with_commit(&dir);
1983 let format = ObjectFormat::Sha1;
1984 let store = FileRefStore::new(&git_dir, format);
1985 let notes_ref = heddle_notes_ref();
1986 let identity = test_identity();
1987 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1988 let blob_a = write_blob(&mut db, br#"{"v":1}"#).expect("blob a");
1989 let blob_b = write_blob(&mut db, br#"{"v":2}"#).expect("blob b");
1990
1991 upsert_note_for(
1992 &git_dir,
1993 format,
1994 &store,
1995 ¬es_ref,
1996 &target,
1997 blob_a,
1998 "heddle: export",
1999 &identity,
2000 None,
2001 )
2002 .expect("seed note");
2003 let head = read_notes_head(&store, ¬es_ref).expect("head");
2004 let wrong =
2005 ObjectId::from_hex(format, "cccccccccccccccccccccccccccccccccccccccc").expect("oid");
2006
2007 let err = upsert_note_for(
2008 &git_dir,
2009 format,
2010 &store,
2011 ¬es_ref,
2012 &target,
2013 blob_b,
2014 "heddle: export",
2015 &identity,
2016 Some(RefTarget::Direct(wrong)),
2017 )
2018 .expect_err("cas mismatch");
2019 assert!(matches!(err, GitError::Transaction(_)));
2020 assert_eq!(read_notes_head(&store, ¬es_ref), Some(head));
2021 let _ = fs::remove_dir_all(&dir);
2022 }
2023
2024 #[test]
2025 fn remove_notes_for_partial_hit() {
2026 let dir = unique_temp_dir("remove-partial");
2027 fs::create_dir_all(&dir).expect("create temp dir");
2028 let (git_dir, target) = init_repo_with_commit(&dir);
2029 let format = ObjectFormat::Sha1;
2030 let store = FileRefStore::new(&git_dir, format);
2031 let notes_ref = heddle_notes_ref();
2032 let identity = test_identity();
2033 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2034 let other =
2035 ObjectId::from_hex(format, "dddddddddddddddddddddddddddddddddddddddd").expect("oid");
2036 let blob_a = write_blob(&mut db, br#"{"a":1}"#).expect("blob a");
2037 let blob_b = write_blob(&mut db, br#"{"b":2}"#).expect("blob b");
2038
2039 write_notes(
2040 &git_dir,
2041 format,
2042 &store,
2043 ¬es_ref,
2044 &[
2045 Note {
2046 annotated: target,
2047 blob: blob_a,
2048 },
2049 Note {
2050 annotated: other,
2051 blob: blob_b,
2052 },
2053 ],
2054 "seed",
2055 &identity,
2056 None,
2057 )
2058 .expect("seed notes");
2059 let head = read_notes_head(&store, ¬es_ref).expect("head");
2060
2061 let outcome = remove_notes_for(
2062 &git_dir,
2063 format,
2064 &store,
2065 ¬es_ref,
2066 &[target],
2067 "heddle: retract",
2068 &identity,
2069 Some(RefTarget::Direct(head)),
2070 )
2071 .expect("remove");
2072 assert!(matches!(outcome, RemoveNoteOutcome::Removed { .. }));
2073 assert_eq!(
2074 read_note(&git_dir, format, &store, ¬es_ref, &target).expect("read"),
2075 None
2076 );
2077 assert_eq!(
2078 read_note(&git_dir, format, &store, ¬es_ref, &other).expect("read"),
2079 Some(blob_b)
2080 );
2081 let _ = fs::remove_dir_all(&dir);
2082 }
2083
2084 #[test]
2085 fn remove_notes_for_noop_when_missing() {
2086 let dir = unique_temp_dir("remove-noop");
2087 fs::create_dir_all(&dir).expect("create temp dir");
2088 let (git_dir, target) = init_repo_with_commit(&dir);
2089 let format = ObjectFormat::Sha1;
2090 let store = FileRefStore::new(&git_dir, format);
2091 let notes_ref = heddle_notes_ref();
2092 let identity = test_identity();
2093 let missing =
2094 ObjectId::from_hex(format, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee").expect("oid");
2095
2096 let absent = remove_notes_for(
2097 &git_dir,
2098 format,
2099 &store,
2100 ¬es_ref,
2101 &[target],
2102 "heddle: retract",
2103 &identity,
2104 None,
2105 )
2106 .expect("remove absent ref");
2107 assert_eq!(absent, RemoveNoteOutcome::Unchanged);
2108
2109 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2110 let blob = write_blob(&mut db, br#"{"x":1}"#).expect("blob");
2111 upsert_note_for(
2112 &git_dir,
2113 format,
2114 &store,
2115 ¬es_ref,
2116 &target,
2117 blob,
2118 "heddle: export",
2119 &identity,
2120 None,
2121 )
2122 .expect("seed");
2123 let head = read_notes_head(&store, ¬es_ref).expect("head");
2124
2125 let noop = remove_notes_for(
2126 &git_dir,
2127 format,
2128 &store,
2129 ¬es_ref,
2130 &[missing],
2131 "heddle: retract",
2132 &identity,
2133 Some(RefTarget::Direct(head)),
2134 )
2135 .expect("remove missing oid");
2136 assert_eq!(noop, RemoveNoteOutcome::Unchanged);
2137 assert_eq!(read_notes_head(&store, ¬es_ref), Some(head));
2138 let _ = fs::remove_dir_all(&dir);
2139 }
2140
2141 #[test]
2142 fn remove_notes_for_batch_single_commit() {
2143 let dir = unique_temp_dir("remove-batch");
2144 fs::create_dir_all(&dir).expect("create temp dir");
2145 let (git_dir, _) = init_repo_with_commit(&dir);
2146 let format = ObjectFormat::Sha1;
2147 let store = FileRefStore::new(&git_dir, format);
2148 let notes_ref = heddle_notes_ref();
2149 let identity = test_identity();
2150 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2151 let first =
2152 ObjectId::from_hex(format, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("oid");
2153 let second =
2154 ObjectId::from_hex(format, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").expect("oid");
2155 let third =
2156 ObjectId::from_hex(format, "cccccccccccccccccccccccccccccccccccccccc").expect("oid");
2157 let blob_a = write_blob(&mut db, b"a\n").expect("blob");
2158 let blob_b = write_blob(&mut db, b"b\n").expect("blob");
2159 let blob_c = write_blob(&mut db, b"c\n").expect("blob");
2160
2161 write_notes(
2162 &git_dir,
2163 format,
2164 &store,
2165 ¬es_ref,
2166 &[
2167 Note {
2168 annotated: first,
2169 blob: blob_a,
2170 },
2171 Note {
2172 annotated: second,
2173 blob: blob_b,
2174 },
2175 Note {
2176 annotated: third,
2177 blob: blob_c,
2178 },
2179 ],
2180 "seed",
2181 &identity,
2182 None,
2183 )
2184 .expect("seed");
2185 let head = read_notes_head(&store, ¬es_ref).expect("head");
2186
2187 let RemoveNoteOutcome::Removed { notes_commit } = remove_notes_for(
2188 &git_dir,
2189 format,
2190 &store,
2191 ¬es_ref,
2192 &[first, second],
2193 "heddle: retract",
2194 &identity,
2195 Some(RefTarget::Direct(head)),
2196 )
2197 .expect("batch remove") else {
2198 panic!("expected removal");
2199 };
2200
2201 let db = FileObjectDatabase::from_git_dir(&git_dir, format);
2202 let commit = db.read_object(¬es_commit).expect("read commit");
2203 let commit = Commit::parse_ref(format, &commit.body).expect("parse");
2204 assert_eq!(commit.parents.len(), 1);
2205 assert_eq!(commit.parents[0], head);
2206 assert_eq!(
2207 list_notes(&git_dir, format, &store, ¬es_ref)
2208 .expect("list")
2209 .len(),
2210 1
2211 );
2212 let _ = fs::remove_dir_all(&dir);
2213 }
2214
2215 #[test]
2216 fn incremental_ops_read_fanout_legacy() {
2217 let dir = unique_temp_dir("incremental-fanout");
2218 fs::create_dir_all(&dir).expect("create temp dir");
2219 let (git_dir, target) = init_repo_with_commit(&dir);
2220 let format = ObjectFormat::Sha1;
2221 let store = FileRefStore::new(&git_dir, format);
2222 let notes_ref = heddle_notes_ref();
2223 let identity = test_identity();
2224 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2225 let blob = write_blob(&mut db, br#"{"legacy":true}"#).expect("blob");
2226
2227 install_fanout_note(&git_dir, &store, ¬es_ref, &target, blob, &identity);
2228 let head = read_notes_head(&store, ¬es_ref).expect("head");
2229 let new_blob = write_blob(&mut db, br#"{"legacy":false}"#).expect("new blob");
2230
2231 upsert_note_for(
2232 &git_dir,
2233 format,
2234 &store,
2235 ¬es_ref,
2236 &target,
2237 new_blob,
2238 "heddle: export",
2239 &identity,
2240 Some(RefTarget::Direct(head)),
2241 )
2242 .expect("upsert fanout");
2243
2244 assert_eq!(
2245 read_note_for(&git_dir, format, &store, ¬es_ref, &target).expect("read"),
2246 Some(new_blob)
2247 );
2248 let _ = fs::remove_dir_all(&dir);
2249 }
2250
2251 #[test]
2252 fn incremental_ops_ff_chain() {
2253 let dir = unique_temp_dir("incremental-ff");
2254 fs::create_dir_all(&dir).expect("create temp dir");
2255 let (git_dir, target) = init_repo_with_commit(&dir);
2256 let format = ObjectFormat::Sha1;
2257 let store = FileRefStore::new(&git_dir, format);
2258 let notes_ref = heddle_notes_ref();
2259 let identity = test_identity();
2260 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2261 let other =
2262 ObjectId::from_hex(format, "ffffffffffffffffffffffffffffffffffffffff").expect("oid");
2263 let blob_a = write_blob(&mut db, br#"{"first":true}"#).expect("blob a");
2264 let blob_b = write_blob(&mut db, br#"{"second":true}"#).expect("blob b");
2265
2266 let UpsertNoteOutcome::Updated {
2267 notes_commit: first_commit,
2268 } = upsert_note_for(
2269 &git_dir,
2270 format,
2271 &store,
2272 ¬es_ref,
2273 &target,
2274 blob_a,
2275 "heddle: export",
2276 &identity,
2277 None,
2278 )
2279 .expect("first upsert")
2280 else {
2281 panic!("expected update");
2282 };
2283
2284 let UpsertNoteOutcome::Updated {
2285 notes_commit: second_commit,
2286 } = upsert_note_for(
2287 &git_dir,
2288 format,
2289 &store,
2290 ¬es_ref,
2291 &other,
2292 blob_b,
2293 "heddle: export",
2294 &identity,
2295 Some(RefTarget::Direct(first_commit)),
2296 )
2297 .expect("second upsert")
2298 else {
2299 panic!("expected update");
2300 };
2301
2302 let db = FileObjectDatabase::from_git_dir(&git_dir, format);
2303 let object = db.read_object(&second_commit).expect("read commit");
2304 let commit = Commit::parse_ref(format, &object.body).expect("parse");
2305 assert_eq!(commit.parents, vec![first_commit]);
2306 let _ = fs::remove_dir_all(&dir);
2307 }
2308}