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