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