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::HashSet;
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
84pub fn resolve_notes_ref(git_dir: &Path, ref_override: Option<&str>) -> Result<NotesRef> {
87 resolve_notes_ref_impl(git_dir, ref_override, None)
88}
89
90pub fn resolve_notes_ref_with_config(
100 git_dir: &Path,
101 ref_override: Option<&str>,
102 config: &GitConfig,
103) -> Result<NotesRef> {
104 resolve_notes_ref_impl(git_dir, ref_override, Some(config))
105}
106
107fn resolve_notes_ref_impl(
108 git_dir: &Path,
109 ref_override: Option<&str>,
110 config: Option<&GitConfig>,
111) -> Result<NotesRef> {
112 if let Some(value) = ref_override {
113 return Ok(NotesRef::expand(value));
114 }
115 if let Ok(value) = std::env::var("GIT_NOTES_REF")
116 && !value.is_empty()
117 {
118 return Ok(NotesRef::expand(&value));
119 }
120 let owned_config;
123 let config = match config {
124 Some(config) => Some(config),
125 None => match read_repo_config(git_dir) {
126 Ok(config) => {
127 owned_config = config;
128 Some(&owned_config)
129 }
130 Err(_) => None,
131 },
132 };
133 if let Some(config) = config
134 && let Some(value) = config.get("core", None, "notesRef")
135 && !value.is_empty()
136 {
137 return Ok(NotesRef::expand(value));
138 }
139 Ok(NotesRef::expand(DEFAULT_NOTES_REF))
140}
141
142pub struct NotesIter {
144 db: FileObjectDatabase,
145 format: ObjectFormat,
146 stack: Vec<(ObjectId, String)>,
147 pending: Vec<Note>,
148}
149
150impl NotesIter {
151 fn new(
152 git_dir: &Path,
153 format: ObjectFormat,
154 store: &FileRefStore,
155 notes_ref: &NotesRef,
156 ) -> Result<Self> {
157 let Some(tree_oid) = notes_tree_oid(git_dir, format, store, notes_ref)? else {
158 return Ok(Self {
159 db: FileObjectDatabase::from_git_dir(git_dir, format),
160 format,
161 stack: Vec::new(),
162 pending: Vec::new(),
163 });
164 };
165 Ok(Self {
166 db: FileObjectDatabase::from_git_dir(git_dir, format),
167 format,
168 stack: vec![(tree_oid, String::new())],
169 pending: Vec::new(),
170 })
171 }
172}
173
174impl Iterator for NotesIter {
175 type Item = Result<Note>;
176
177 fn next(&mut self) -> Option<Self::Item> {
178 loop {
179 if let Some(note) = self.pending.pop() {
180 return Some(Ok(note));
181 }
182 let (tree_oid, prefix) = self.stack.pop()?;
183 let entries = match load_hex_tree_entries(&self.db, self.format, &tree_oid) {
184 Ok(entries) => entries,
185 Err(err) => return Some(Err(err)),
186 };
187 for (name, mode, oid) in entries.into_iter().rev() {
188 if tree_entry_object_type(mode) == ObjectType::Tree {
189 let mut nested = prefix.clone();
190 nested.push_str(&name);
191 self.stack.push((oid, nested));
192 } else {
193 let mut hex = prefix.clone();
194 hex.push_str(&name);
195 if hex.len() != self.format.hex_len() {
196 continue;
197 }
198 let Ok(annotated) = ObjectId::from_hex(self.format, &hex) else {
199 continue;
200 };
201 self.pending.push(Note {
202 annotated,
203 blob: oid,
204 });
205 }
206 }
207 }
208 }
209}
210
211pub fn iter_notes(
213 git_dir: &Path,
214 format: ObjectFormat,
215 store: &FileRefStore,
216 notes_ref: &NotesRef,
217) -> Result<NotesIter> {
218 NotesIter::new(git_dir, format, store, notes_ref)
219}
220
221pub fn list_notes(
223 git_dir: &Path,
224 format: ObjectFormat,
225 store: &FileRefStore,
226 notes_ref: &NotesRef,
227) -> Result<Vec<Note>> {
228 let mut notes = iter_notes(git_dir, format, store, notes_ref)?.collect::<Result<Vec<_>>>()?;
229 notes.sort_by_key(|entry| entry.annotated.to_hex());
230 Ok(notes)
231}
232
233pub fn read_note_for(
235 git_dir: &Path,
236 format: ObjectFormat,
237 store: &FileRefStore,
238 notes_ref: &NotesRef,
239 annotated: &ObjectId,
240) -> Result<Option<ObjectId>> {
241 let Some(tree_oid) = notes_tree_oid(git_dir, format, store, notes_ref)? else {
242 return Ok(None);
243 };
244 let db = FileObjectDatabase::from_git_dir(git_dir, format);
245 lookup_note_for(&db, format, &tree_oid, "", &annotated.to_hex())
246}
247
248pub fn read_note(
250 git_dir: &Path,
251 format: ObjectFormat,
252 store: &FileRefStore,
253 notes_ref: &NotesRef,
254 annotated: &ObjectId,
255) -> Result<Option<ObjectId>> {
256 read_note_for(git_dir, format, store, notes_ref, annotated)
257}
258
259pub fn read_note_bytes(
261 git_dir: &Path,
262 format: ObjectFormat,
263 store: &FileRefStore,
264 notes_ref: &NotesRef,
265 annotated: &ObjectId,
266) -> Result<Option<Vec<u8>>> {
267 let Some(blob) = read_note(git_dir, format, store, notes_ref, annotated)? else {
268 return Ok(None);
269 };
270 let db = FileObjectDatabase::from_git_dir(git_dir, format);
271 let object = db.read_object(&blob)?;
272 if object.object_type != ObjectType::Blob {
273 return Err(GitError::InvalidFormat(format!(
274 "note for {} is not a blob",
275 annotated.to_hex()
276 )));
277 }
278 Ok(Some(object.body.to_vec()))
279}
280
281pub fn notes_ref_expected(store: &FileRefStore, notes_ref: &NotesRef) -> Result<Option<RefTarget>> {
285 Ok(match store.read_ref(notes_ref.as_str())? {
286 Some(RefTarget::Direct(oid)) => Some(RefTarget::Direct(oid)),
287 _ => None,
288 })
289}
290
291#[allow(clippy::too_many_arguments)]
298pub fn write_notes(
299 git_dir: &Path,
300 format: ObjectFormat,
301 store: &FileRefStore,
302 notes_ref: &NotesRef,
303 notes: &[Note],
304 message: &str,
305 identity: &NotesCommitIdentity,
306 ref_expected: Option<RefTarget>,
307) -> Result<()> {
308 commit_notes_update(
309 git_dir,
310 format,
311 store,
312 notes_ref,
313 notes,
314 message,
315 identity,
316 ref_expected,
317 )?;
318 Ok(())
319}
320
321#[allow(clippy::too_many_arguments)]
325pub fn upsert_note_for(
326 git_dir: &Path,
327 format: ObjectFormat,
328 store: &FileRefStore,
329 notes_ref: &NotesRef,
330 annotated: &ObjectId,
331 blob: ObjectId,
332 message: &str,
333 identity: &NotesCommitIdentity,
334 ref_expected: Option<RefTarget>,
335) -> Result<UpsertNoteOutcome> {
336 if let Some(existing) = read_note_for(git_dir, format, store, notes_ref, annotated)?
337 && existing == blob
338 {
339 return Ok(UpsertNoteOutcome::Unchanged);
340 }
341 let mut notes = list_notes(git_dir, format, store, notes_ref)?;
342 upsert_note(&mut notes, annotated, blob);
343 let notes_commit = commit_notes_update(
344 git_dir,
345 format,
346 store,
347 notes_ref,
348 ¬es,
349 message,
350 identity,
351 ref_expected,
352 )?;
353 Ok(UpsertNoteOutcome::Updated { notes_commit })
354}
355
356#[allow(clippy::too_many_arguments)]
358pub fn upsert_note_bytes_for(
359 git_dir: &Path,
360 format: ObjectFormat,
361 store: &FileRefStore,
362 notes_ref: &NotesRef,
363 annotated: &ObjectId,
364 body: &[u8],
365 message: &str,
366 identity: &NotesCommitIdentity,
367 ref_expected: Option<RefTarget>,
368) -> Result<UpsertNoteOutcome> {
369 let db = FileObjectDatabase::from_git_dir(git_dir, format);
370 let blob = db.write_object(EncodedObject::new(ObjectType::Blob, body.to_vec()))?;
371 upsert_note_for(
372 git_dir,
373 format,
374 store,
375 notes_ref,
376 annotated,
377 blob,
378 message,
379 identity,
380 ref_expected,
381 )
382}
383
384#[allow(clippy::too_many_arguments)]
386pub fn remove_note_for(
387 git_dir: &Path,
388 format: ObjectFormat,
389 store: &FileRefStore,
390 notes_ref: &NotesRef,
391 annotated: &ObjectId,
392 message: &str,
393 identity: &NotesCommitIdentity,
394 ref_expected: Option<RefTarget>,
395) -> Result<RemoveNoteOutcome> {
396 remove_notes_for(
397 git_dir,
398 format,
399 store,
400 notes_ref,
401 std::slice::from_ref(annotated),
402 message,
403 identity,
404 ref_expected,
405 )
406}
407
408#[allow(clippy::too_many_arguments)]
412pub fn remove_notes_for(
413 git_dir: &Path,
414 format: ObjectFormat,
415 store: &FileRefStore,
416 notes_ref: &NotesRef,
417 annotated: &[ObjectId],
418 message: &str,
419 identity: &NotesCommitIdentity,
420 ref_expected: Option<RefTarget>,
421) -> Result<RemoveNoteOutcome> {
422 if annotated.is_empty() || notes_head_oid(store, notes_ref)?.is_none() {
423 return Ok(RemoveNoteOutcome::Unchanged);
424 }
425 let targets: HashSet<_> = annotated.iter().collect();
426 let mut notes = list_notes(git_dir, format, store, notes_ref)?;
427 let before = notes.len();
428 notes.retain(|note| !targets.contains(¬e.annotated));
429 if notes.len() == before {
430 return Ok(RemoveNoteOutcome::Unchanged);
431 }
432 let notes_commit = commit_notes_update(
433 git_dir,
434 format,
435 store,
436 notes_ref,
437 ¬es,
438 message,
439 identity,
440 ref_expected,
441 )?;
442 Ok(RemoveNoteOutcome::Removed { notes_commit })
443}
444
445pub fn upsert_note(notes: &mut Vec<Note>, annotated: &ObjectId, blob: ObjectId) {
447 let target_hex = annotated.to_hex();
448 if let Some(existing) = notes
449 .iter_mut()
450 .find(|entry| entry.annotated.to_hex() == target_hex)
451 {
452 existing.blob = blob;
453 } else {
454 notes.push(Note {
455 annotated: *annotated,
456 blob,
457 });
458 }
459}
460
461pub fn remove_note(notes: &mut Vec<Note>, annotated: &ObjectId) {
463 let target_hex = annotated.to_hex();
464 notes.retain(|entry| entry.annotated.to_hex() != target_hex);
465}
466
467pub fn notes_tree_oid(
469 git_dir: &Path,
470 format: ObjectFormat,
471 store: &FileRefStore,
472 notes_ref: &NotesRef,
473) -> Result<Option<ObjectId>> {
474 let Some(target) = store.read_ref(notes_ref.as_str())? else {
475 return Ok(None);
476 };
477 let commit_oid = match target {
478 RefTarget::Direct(oid) => oid,
479 RefTarget::Symbolic(name) => match store.read_ref(&name)? {
480 Some(RefTarget::Direct(oid)) => oid,
481 _ => return Ok(None),
482 },
483 };
484 let db = FileObjectDatabase::from_git_dir(git_dir, format);
485 let object = db.read_object(&commit_oid)?;
486 match object.object_type {
487 ObjectType::Commit => Ok(Some(Commit::parse_ref(format, &object.body)?.tree)),
488 ObjectType::Tree => Ok(Some(commit_oid)),
489 _ => Ok(None),
490 }
491}
492
493fn load_hex_tree_entries(
494 db: &FileObjectDatabase,
495 format: ObjectFormat,
496 tree_oid: &ObjectId,
497) -> Result<Vec<(String, u32, ObjectId)>> {
498 let object = db.read_object(tree_oid)?;
499 if object.object_type != ObjectType::Tree {
500 return Ok(Vec::new());
501 }
502 let mut out = Vec::new();
503 for entry in TreeEntries::new(format, &object.body) {
504 let entry = entry?;
505 let Ok(name) = std::str::from_utf8(entry.name) else {
506 continue;
507 };
508 if !is_hex_name(name) {
509 continue;
510 }
511 out.push((name.to_string(), entry.mode, entry.oid));
512 }
513 Ok(out)
514}
515
516fn lookup_note_for(
517 db: &FileObjectDatabase,
518 format: ObjectFormat,
519 tree_oid: &ObjectId,
520 prefix: &str,
521 target_hex: &str,
522) -> Result<Option<ObjectId>> {
523 for (name, mode, oid) in load_hex_tree_entries(db, format, tree_oid)? {
524 let mut hex = prefix.to_string();
525 hex.push_str(&name);
526 if tree_entry_object_type(mode) == ObjectType::Tree {
527 if !target_hex.starts_with(&hex) {
528 continue;
529 }
530 if let Some(blob) = lookup_note_for(db, format, &oid, &hex, target_hex)? {
531 return Ok(Some(blob));
532 }
533 } else if hex == target_hex {
534 return Ok(Some(oid));
535 }
536 }
537 Ok(None)
538}
539
540fn is_hex_name(name: &str) -> bool {
541 !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_hexdigit())
542}
543
544fn expand_notes_ref(name: &str) -> String {
545 if name.starts_with("refs/notes/") {
546 name.to_string()
547 } else {
548 format!("refs/notes/{name}")
549 }
550}
551
552fn read_repo_config(git_dir: &Path) -> Result<GitConfig> {
558 sley_config::read_repo_config(git_dir, None)
559}
560
561fn notes_head_oid(store: &FileRefStore, notes_ref: &NotesRef) -> Result<Option<ObjectId>> {
562 Ok(match store.read_ref(notes_ref.as_str())? {
563 Some(RefTarget::Direct(oid)) => Some(oid),
564 _ => None,
565 })
566}
567
568#[allow(clippy::too_many_arguments)]
569fn commit_notes_update(
570 git_dir: &Path,
571 format: ObjectFormat,
572 store: &FileRefStore,
573 notes_ref: &NotesRef,
574 notes: &[Note],
575 message: &str,
576 identity: &NotesCommitIdentity,
577 ref_expected: Option<RefTarget>,
578) -> Result<ObjectId> {
579 let mut db = FileObjectDatabase::from_git_dir(git_dir, format);
580 let parent = notes_head_oid(store, notes_ref)?;
581
582 let mut entries: Vec<TreeEntry> = notes
583 .iter()
584 .map(|note| TreeEntry {
585 mode: 0o100644,
586 name: BString::from(note.annotated.to_hex().as_bytes()),
587 oid: note.blob,
588 })
589 .collect();
590 entries.sort_by(|left, right| left.name.cmp(&right.name));
591 let tree = Tree { entries };
592 let tree_oid = db.write_object(EncodedObject::new(ObjectType::Tree, tree.write()))?;
593
594 let parents = parent.iter().cloned().collect();
595 let commit_oid = create_commit(
596 &mut db,
597 CommitCreate {
598 tree: tree_oid,
599 parents,
600 author: identity.author.clone(),
601 committer: identity.committer.clone(),
602 message: format!("{message}\n").into_bytes(),
603 encoding: None,
604 },
605 )?;
606
607 let old_oid = parent.unwrap_or(zero_oid(format)?);
608 let mut tx = store.transaction();
609 tx.update(RefUpdate {
610 name: notes_ref.as_str().to_string(),
611 expected: ref_expected,
612 new: RefTarget::Direct(commit_oid),
613 reflog: Some(ReflogEntry {
614 old_oid,
615 new_oid: commit_oid,
616 committer: identity.committer.clone(),
617 message: format!("notes: {message}").into_bytes(),
620 }),
621 });
622 tx.commit()?;
623 Ok(commit_oid)
624}
625
626fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
627 ObjectId::from_hex(format, &"0".repeat(format.hex_len()))
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use sley_sequencer::format_commit_identity;
634 use std::fs;
635 use std::path::{Path, PathBuf};
636 use std::process::{Command, Stdio};
637 use std::time::{SystemTime, UNIX_EPOCH};
638
639 const NAME: &str = "Tester";
640 const EMAIL: &str = "tester@example.com";
641 const DATE: &str = "@1790000000 -0500";
642
643 fn unique_temp_dir(name: &str) -> PathBuf {
644 let nanos = SystemTime::now()
645 .duration_since(UNIX_EPOCH)
646 .expect("system time before unix epoch")
647 .as_nanos();
648 std::env::temp_dir().join(format!("sley-notes-{name}-{}-{nanos}", std::process::id()))
649 }
650
651 fn git_available() -> bool {
652 Command::new("git")
653 .arg("--version")
654 .stdout(Stdio::null())
655 .stderr(Stdio::null())
656 .status()
657 .map(|status| status.success())
658 .unwrap_or(false)
659 }
660
661 fn test_identity() -> NotesCommitIdentity {
662 NotesCommitIdentity {
663 author: format_commit_identity(NAME, EMAIL, DATE)
664 .expect("test operation should succeed"),
665 committer: format_commit_identity(NAME, EMAIL, DATE)
666 .expect("test operation should succeed"),
667 }
668 }
669
670 fn git_env(command: &mut Command) -> &mut Command {
671 command
672 .env("GIT_AUTHOR_NAME", NAME)
673 .env("GIT_AUTHOR_EMAIL", EMAIL)
674 .env("GIT_AUTHOR_DATE", DATE)
675 .env("GIT_COMMITTER_NAME", NAME)
676 .env("GIT_COMMITTER_EMAIL", EMAIL)
677 .env("GIT_COMMITTER_DATE", DATE)
678 }
679
680 fn init_repo_with_commit(root: &Path) -> (PathBuf, ObjectId) {
681 let mut init = Command::new("git");
682 git_env(init.current_dir(root).args(["init", "-q"]))
683 .status()
684 .expect("git init should succeed");
685 fs::write(root.join("f.txt"), b"content\n").expect("write worktree file");
686 let mut add = Command::new("git");
687 git_env(add.current_dir(root).args(["add", "f.txt"]))
688 .status()
689 .expect("git add should succeed");
690 let mut commit = Command::new("git");
691 git_env(commit.current_dir(root).args(["commit", "-q", "-m", "c1"]))
692 .status()
693 .expect("git commit should succeed");
694 let git_dir = root.join(".git");
695 let format = ObjectFormat::Sha1;
696 let store = FileRefStore::new(&git_dir, format);
697 let head = store
698 .read_ref("HEAD")
699 .expect("read HEAD")
700 .expect("HEAD should exist");
701 let oid = match head {
702 RefTarget::Direct(oid) => oid,
703 RefTarget::Symbolic(name) => match store.read_ref(&name).expect("read symref") {
704 Some(RefTarget::Direct(oid)) => oid,
705 other => panic!("unexpected symref target: {other:?}"),
706 },
707 };
708 (git_dir, oid)
709 }
710
711 fn write_blob(db: &mut FileObjectDatabase, bytes: &[u8]) -> Result<ObjectId> {
712 db.write_object(EncodedObject::new(ObjectType::Blob, bytes.to_vec()))
713 }
714
715 #[test]
716 fn notes_ref_expand_qualifies_names() {
717 assert_eq!(NotesRef::expand("commits").as_str(), "refs/notes/commits");
718 assert_eq!(
719 NotesRef::expand("refs/notes/review").as_str(),
720 "refs/notes/review"
721 );
722 }
723
724 #[test]
725 fn read_write_list_round_trip() {
726 let dir = unique_temp_dir("round-trip");
727 fs::create_dir_all(&dir).expect("create temp dir");
728 let (git_dir, target) = init_repo_with_commit(&dir);
729 let format = ObjectFormat::Sha1;
730 let store = FileRefStore::new(&git_dir, format);
731 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
732 let identity = test_identity();
733 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
734 let blob = write_blob(&mut db, b"hello note\n").expect("test operation should succeed");
735
736 let mut notes = Vec::new();
737 upsert_note(&mut notes, &target, blob);
738 write_notes(
739 &git_dir,
740 format,
741 &store,
742 ¬es_ref,
743 ¬es,
744 "Notes added by test",
745 &identity,
746 notes_ref_expected(&store, ¬es_ref).expect("ref expected"),
747 )
748 .expect("test operation should succeed");
749
750 let listed = list_notes(&git_dir, format, &store, ¬es_ref)
751 .expect("test operation should succeed");
752 assert_eq!(listed.len(), 1);
753 assert_eq!(listed[0].annotated, target);
754 assert_eq!(listed[0].blob, blob);
755
756 let read_back = read_note(&git_dir, format, &store, ¬es_ref, &target)
757 .expect("test operation should succeed");
758 assert_eq!(read_back, Some(blob));
759
760 let bytes = read_note_bytes(&git_dir, format, &store, ¬es_ref, &target)
761 .expect("test operation should succeed");
762 assert_eq!(bytes.as_deref(), Some(b"hello note\n" as &[u8]));
763 let _ = fs::remove_dir_all(&dir);
764 }
765
766 #[test]
767 fn iter_notes_matches_list_notes() {
768 let dir = unique_temp_dir("iter-list");
769 fs::create_dir_all(&dir).expect("create temp dir");
770 let (git_dir, target) = init_repo_with_commit(&dir);
771 let format = ObjectFormat::Sha1;
772 let store = FileRefStore::new(&git_dir, format);
773 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
774 let blob = write_blob(&mut db, b"iter note\n").expect("blob");
775 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
776 write_notes(
777 &git_dir,
778 format,
779 &store,
780 ¬es_ref,
781 &[Note {
782 annotated: target,
783 blob,
784 }],
785 "note",
786 &test_identity(),
787 notes_ref_expected(&store, ¬es_ref).expect("ref expected"),
788 )
789 .expect("write notes");
790
791 let listed = list_notes(&git_dir, format, &store, ¬es_ref).expect("list");
792 let mut iter_collected = iter_notes(&git_dir, format, &store, ¬es_ref)
793 .expect("iter")
794 .collect::<Result<Vec<_>>>()
795 .expect("collect");
796 iter_collected.sort_by_key(|entry| entry.annotated.to_hex());
797 assert_eq!(listed, iter_collected);
798 let _ = fs::remove_dir_all(&dir);
799 }
800
801 #[test]
802 fn iter_notes_yields_every_note_in_flat_tree() {
803 let dir = unique_temp_dir("iter-flat-multi");
804 fs::create_dir_all(&dir).expect("create temp dir");
805 let (git_dir, _) = init_repo_with_commit(&dir);
806 let format = ObjectFormat::Sha1;
807 let store = FileRefStore::new(&git_dir, format);
808 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
809 let first =
810 ObjectId::from_hex(format, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("oid");
811 let second =
812 ObjectId::from_hex(format, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").expect("oid");
813 let blob_a = write_blob(&mut db, b"note a\n").expect("blob");
814 let blob_b = write_blob(&mut db, b"note b\n").expect("blob");
815 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
816 write_notes(
817 &git_dir,
818 format,
819 &store,
820 ¬es_ref,
821 &[
822 Note {
823 annotated: first,
824 blob: blob_a,
825 },
826 Note {
827 annotated: second,
828 blob: blob_b,
829 },
830 ],
831 "notes",
832 &test_identity(),
833 notes_ref_expected(&store, ¬es_ref).expect("ref expected"),
834 )
835 .expect("write notes");
836
837 let collected = iter_notes(&git_dir, format, &store, ¬es_ref)
838 .expect("iter")
839 .collect::<Result<Vec<_>>>()
840 .expect("collect");
841 assert_eq!(collected.len(), 2);
842 let _ = fs::remove_dir_all(&dir);
843 }
844
845 #[test]
846 fn read_note_for_skips_unrelated_fanout_branches() {
847 let dir = unique_temp_dir("lookup");
848 fs::create_dir_all(&dir).expect("create temp dir");
849 let (git_dir, target) = init_repo_with_commit(&dir);
850 let format = ObjectFormat::Sha1;
851 let store = FileRefStore::new(&git_dir, format);
852 let target_hex = target.to_hex();
853 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
854 let blob = write_blob(&mut db, b"lookup note\n").expect("blob");
855 let prefix = &target_hex[..2];
856 let suffix = &target_hex[2..];
857 let leaf = Tree {
858 entries: vec![TreeEntry {
859 mode: 0o100644,
860 name: BString::from(suffix.as_bytes()),
861 oid: blob,
862 }],
863 };
864 let leaf_oid = db
865 .write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
866 .expect("leaf");
867 let fanout = Tree {
868 entries: vec![TreeEntry {
869 mode: 0o040000,
870 name: BString::from(prefix.as_bytes()),
871 oid: leaf_oid,
872 }],
873 };
874 let fanout_oid = db
875 .write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
876 .expect("fanout");
877 let identity = test_identity();
878 let commit_oid = create_commit(
879 &mut db,
880 CommitCreate {
881 tree: fanout_oid,
882 parents: Vec::new(),
883 author: identity.author.clone(),
884 committer: identity.committer.clone(),
885 message: b"fanout notes\n".to_vec(),
886 encoding: None,
887 },
888 )
889 .expect("commit");
890 let mut tx = store.transaction();
891 tx.update(RefUpdate {
892 name: DEFAULT_NOTES_REF.to_string(),
893 expected: None,
894 new: RefTarget::Direct(commit_oid),
895 reflog: None,
896 });
897 tx.commit().expect("update ref");
898 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
899 let found = read_note_for(&git_dir, format, &store, ¬es_ref, &target).expect("lookup");
900 assert_eq!(found, Some(blob));
901 let _ = fs::remove_dir_all(&dir);
902 }
903
904 #[test]
905 fn fanout_tree_is_readable() {
906 let dir = unique_temp_dir("fanout");
907 fs::create_dir_all(&dir).expect("create temp dir");
908 let (git_dir, target) = init_repo_with_commit(&dir);
909 let format = ObjectFormat::Sha1;
910 let store = FileRefStore::new(&git_dir, format);
911 let target_hex = target.to_hex();
912 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
913 let blob = write_blob(&mut db, b"fanout note\n").expect("test operation should succeed");
914
915 let prefix = &target_hex[..2];
917 let suffix = &target_hex[2..];
918 let leaf = Tree {
919 entries: vec![TreeEntry {
920 mode: 0o100644,
921 name: BString::from(suffix.as_bytes()),
922 oid: blob,
923 }],
924 };
925 let leaf_oid = db
926 .write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
927 .expect("test operation should succeed");
928 let fanout = Tree {
929 entries: vec![TreeEntry {
930 mode: 0o040000,
931 name: BString::from(prefix.as_bytes()),
932 oid: leaf_oid,
933 }],
934 };
935 let fanout_oid = db
936 .write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
937 .expect("test operation should succeed");
938
939 let identity = test_identity();
940 let commit_oid = create_commit(
941 &mut db,
942 CommitCreate {
943 tree: fanout_oid,
944 parents: Vec::new(),
945 author: identity.author.clone(),
946 committer: identity.committer.clone(),
947 message: b"fanout notes\n".to_vec(),
948 encoding: None,
949 },
950 )
951 .expect("test operation should succeed");
952 let mut tx = store.transaction();
953 tx.update(RefUpdate {
954 name: DEFAULT_NOTES_REF.to_string(),
955 expected: None,
956 new: RefTarget::Direct(commit_oid),
957 reflog: None,
958 });
959 tx.commit().expect("test operation should succeed");
960
961 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
962 let read_back = read_note(&git_dir, format, &store, ¬es_ref, &target)
963 .expect("test operation should succeed");
964 assert_eq!(read_back, Some(blob));
965 let _ = fs::remove_dir_all(&dir);
966 }
967
968 #[test]
969 fn note_bytes_match_system_git() {
970 if !git_available() {
971 return;
972 }
973 let dir = unique_temp_dir("git-interop");
974 fs::create_dir_all(&dir).expect("test operation should succeed");
975 let result = std::panic::catch_unwind(|| {
976 let (git_dir, target) = init_repo_with_commit(&dir);
977 let format = ObjectFormat::Sha1;
978 let store = FileRefStore::new(&git_dir, format);
979 let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
980
981 let mut git_add_cmd = Command::new("git");
982 let git_add = git_env(git_add_cmd.current_dir(&dir).args([
983 "notes",
984 "add",
985 "-m",
986 "interop note",
987 "HEAD",
988 ]))
989 .output()
990 .expect("git notes add should run");
991 assert!(
992 git_add.status.success(),
993 "git notes add failed: {}",
994 String::from_utf8_lossy(&git_add.stderr)
995 );
996
997 let sley_bytes = read_note_bytes(&git_dir, format, &store, ¬es_ref, &target)
998 .expect("test operation should succeed")
999 .expect("note should exist");
1000
1001 let mut git_show_cmd = Command::new("git");
1002 let git_output = git_env(
1003 git_show_cmd
1004 .current_dir(&dir)
1005 .args(["notes", "show", "HEAD"]),
1006 )
1007 .output()
1008 .expect("test operation should succeed");
1009 assert!(
1010 git_output.status.success(),
1011 "git notes show failed: {}",
1012 String::from_utf8_lossy(&git_output.stderr)
1013 );
1014 assert_eq!(sley_bytes, git_output.stdout);
1015 });
1016 let _ = fs::remove_dir_all(&dir);
1017 result.expect("note_bytes_match_system_git assertions");
1018 }
1019
1020 fn heddle_notes_ref() -> NotesRef {
1021 NotesRef::expand("refs/notes/heddle")
1022 }
1023
1024 fn read_notes_head(store: &FileRefStore, notes_ref: &NotesRef) -> Option<ObjectId> {
1025 match store.read_ref(notes_ref.as_str()).expect("read ref") {
1026 Some(RefTarget::Direct(oid)) => Some(oid),
1027 _ => None,
1028 }
1029 }
1030
1031 fn install_fanout_note(
1032 git_dir: &Path,
1033 store: &FileRefStore,
1034 notes_ref: &NotesRef,
1035 annotated: &ObjectId,
1036 blob: ObjectId,
1037 identity: &NotesCommitIdentity,
1038 ) {
1039 let format = ObjectFormat::Sha1;
1040 let annotated_hex = annotated.to_hex();
1041 let mut db = FileObjectDatabase::from_git_dir(git_dir, format);
1042 let prefix = &annotated_hex[..2];
1043 let suffix = &annotated_hex[2..];
1044 let leaf = Tree {
1045 entries: vec![TreeEntry {
1046 mode: 0o100644,
1047 name: BString::from(suffix.as_bytes()),
1048 oid: blob,
1049 }],
1050 };
1051 let leaf_oid = db
1052 .write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
1053 .expect("leaf");
1054 let fanout = Tree {
1055 entries: vec![TreeEntry {
1056 mode: 0o040000,
1057 name: BString::from(prefix.as_bytes()),
1058 oid: leaf_oid,
1059 }],
1060 };
1061 let fanout_oid = db
1062 .write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
1063 .expect("fanout");
1064 let commit_oid = create_commit(
1065 &mut db,
1066 CommitCreate {
1067 tree: fanout_oid,
1068 parents: Vec::new(),
1069 author: identity.author.clone(),
1070 committer: identity.committer.clone(),
1071 message: b"fanout notes\n".to_vec(),
1072 encoding: None,
1073 },
1074 )
1075 .expect("commit");
1076 let mut tx = store.transaction();
1077 tx.update(RefUpdate {
1078 name: notes_ref.as_str().to_string(),
1079 expected: notes_ref_expected(store, notes_ref).expect("ref expected"),
1080 new: RefTarget::Direct(commit_oid),
1081 reflog: None,
1082 });
1083 tx.commit().expect("update ref");
1084 }
1085
1086 #[test]
1087 fn upsert_note_for_unchanged_is_noop() {
1088 let dir = unique_temp_dir("upsert-unchanged");
1089 fs::create_dir_all(&dir).expect("create temp dir");
1090 let (git_dir, target) = init_repo_with_commit(&dir);
1091 let format = ObjectFormat::Sha1;
1092 let store = FileRefStore::new(&git_dir, format);
1093 let notes_ref = heddle_notes_ref();
1094 let identity = test_identity();
1095 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1096 let blob = write_blob(&mut db, br#"{"status":"served"}"#).expect("blob");
1097
1098 let first = upsert_note_for(
1099 &git_dir,
1100 format,
1101 &store,
1102 ¬es_ref,
1103 &target,
1104 blob.clone(),
1105 "heddle: export",
1106 &identity,
1107 None,
1108 )
1109 .expect("first upsert");
1110 let first_head = read_notes_head(&store, ¬es_ref).expect("head");
1111 assert!(matches!(first, UpsertNoteOutcome::Updated { .. }));
1112
1113 let second = upsert_note_for(
1114 &git_dir,
1115 format,
1116 &store,
1117 ¬es_ref,
1118 &target,
1119 blob,
1120 "heddle: export",
1121 &identity,
1122 Some(RefTarget::Direct(first_head)),
1123 )
1124 .expect("second upsert");
1125 assert_eq!(second, UpsertNoteOutcome::Unchanged);
1126 assert_eq!(read_notes_head(&store, ¬es_ref), Some(first_head));
1127 let _ = fs::remove_dir_all(&dir);
1128 }
1129
1130 #[test]
1131 fn upsert_note_for_updates_blob() {
1132 let dir = unique_temp_dir("upsert-update");
1133 fs::create_dir_all(&dir).expect("create temp dir");
1134 let (git_dir, target) = init_repo_with_commit(&dir);
1135 let format = ObjectFormat::Sha1;
1136 let store = FileRefStore::new(&git_dir, format);
1137 let notes_ref = heddle_notes_ref();
1138 let identity = test_identity();
1139 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1140 let blob_a = write_blob(&mut db, br#"{"v":1}"#).expect("blob a");
1141 let blob_b = write_blob(&mut db, br#"{"v":2}"#).expect("blob b");
1142
1143 let first = upsert_note_for(
1144 &git_dir,
1145 format,
1146 &store,
1147 ¬es_ref,
1148 &target,
1149 blob_a,
1150 "heddle: export",
1151 &identity,
1152 None,
1153 )
1154 .expect("first upsert");
1155 let UpsertNoteOutcome::Updated {
1156 notes_commit: first_commit,
1157 } = first
1158 else {
1159 panic!("expected first upsert to update");
1160 };
1161
1162 let second = upsert_note_for(
1163 &git_dir,
1164 format,
1165 &store,
1166 ¬es_ref,
1167 &target,
1168 blob_b,
1169 "heddle: export",
1170 &identity,
1171 Some(RefTarget::Direct(first_commit)),
1172 )
1173 .expect("second upsert");
1174 let UpsertNoteOutcome::Updated {
1175 notes_commit: second_commit,
1176 } = second
1177 else {
1178 panic!("expected second upsert to update");
1179 };
1180 assert_ne!(first_commit, second_commit);
1181 assert_eq!(
1182 read_note(&git_dir, format, &store, ¬es_ref, &target).expect("read"),
1183 Some(blob_b)
1184 );
1185 let _ = fs::remove_dir_all(&dir);
1186 }
1187
1188 #[test]
1189 fn upsert_note_for_creates_ref() {
1190 let dir = unique_temp_dir("upsert-create");
1191 fs::create_dir_all(&dir).expect("create temp dir");
1192 let (git_dir, target) = init_repo_with_commit(&dir);
1193 let format = ObjectFormat::Sha1;
1194 let store = FileRefStore::new(&git_dir, format);
1195 let notes_ref = heddle_notes_ref();
1196 let identity = test_identity();
1197 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1198 let blob = write_blob(&mut db, br#"{"status":"served"}"#).expect("blob");
1199
1200 assert_eq!(read_notes_head(&store, ¬es_ref), None);
1201 let outcome = upsert_note_for(
1202 &git_dir,
1203 format,
1204 &store,
1205 ¬es_ref,
1206 &target,
1207 blob,
1208 "heddle: export",
1209 &identity,
1210 None,
1211 )
1212 .expect("upsert");
1213 assert!(matches!(outcome, UpsertNoteOutcome::Updated { .. }));
1214 assert!(read_notes_head(&store, ¬es_ref).is_some());
1215 let _ = fs::remove_dir_all(&dir);
1216 }
1217
1218 #[test]
1219 fn upsert_note_for_cas_mismatch_fails() {
1220 let dir = unique_temp_dir("upsert-cas");
1221 fs::create_dir_all(&dir).expect("create temp dir");
1222 let (git_dir, target) = init_repo_with_commit(&dir);
1223 let format = ObjectFormat::Sha1;
1224 let store = FileRefStore::new(&git_dir, format);
1225 let notes_ref = heddle_notes_ref();
1226 let identity = test_identity();
1227 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1228 let blob_a = write_blob(&mut db, br#"{"v":1}"#).expect("blob a");
1229 let blob_b = write_blob(&mut db, br#"{"v":2}"#).expect("blob b");
1230
1231 upsert_note_for(
1232 &git_dir,
1233 format,
1234 &store,
1235 ¬es_ref,
1236 &target,
1237 blob_a,
1238 "heddle: export",
1239 &identity,
1240 None,
1241 )
1242 .expect("seed note");
1243 let head = read_notes_head(&store, ¬es_ref).expect("head");
1244 let wrong =
1245 ObjectId::from_hex(format, "cccccccccccccccccccccccccccccccccccccccc").expect("oid");
1246
1247 let err = upsert_note_for(
1248 &git_dir,
1249 format,
1250 &store,
1251 ¬es_ref,
1252 &target,
1253 blob_b,
1254 "heddle: export",
1255 &identity,
1256 Some(RefTarget::Direct(wrong)),
1257 )
1258 .expect_err("cas mismatch");
1259 assert!(matches!(err, GitError::Transaction(_)));
1260 assert_eq!(read_notes_head(&store, ¬es_ref), Some(head));
1261 let _ = fs::remove_dir_all(&dir);
1262 }
1263
1264 #[test]
1265 fn remove_notes_for_partial_hit() {
1266 let dir = unique_temp_dir("remove-partial");
1267 fs::create_dir_all(&dir).expect("create temp dir");
1268 let (git_dir, target) = init_repo_with_commit(&dir);
1269 let format = ObjectFormat::Sha1;
1270 let store = FileRefStore::new(&git_dir, format);
1271 let notes_ref = heddle_notes_ref();
1272 let identity = test_identity();
1273 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1274 let other =
1275 ObjectId::from_hex(format, "dddddddddddddddddddddddddddddddddddddddd").expect("oid");
1276 let blob_a = write_blob(&mut db, br#"{"a":1}"#).expect("blob a");
1277 let blob_b = write_blob(&mut db, br#"{"b":2}"#).expect("blob b");
1278
1279 write_notes(
1280 &git_dir,
1281 format,
1282 &store,
1283 ¬es_ref,
1284 &[
1285 Note {
1286 annotated: target,
1287 blob: blob_a,
1288 },
1289 Note {
1290 annotated: other,
1291 blob: blob_b,
1292 },
1293 ],
1294 "seed",
1295 &identity,
1296 None,
1297 )
1298 .expect("seed notes");
1299 let head = read_notes_head(&store, ¬es_ref).expect("head");
1300
1301 let outcome = remove_notes_for(
1302 &git_dir,
1303 format,
1304 &store,
1305 ¬es_ref,
1306 &[target],
1307 "heddle: retract",
1308 &identity,
1309 Some(RefTarget::Direct(head)),
1310 )
1311 .expect("remove");
1312 assert!(matches!(outcome, RemoveNoteOutcome::Removed { .. }));
1313 assert_eq!(
1314 read_note(&git_dir, format, &store, ¬es_ref, &target).expect("read"),
1315 None
1316 );
1317 assert_eq!(
1318 read_note(&git_dir, format, &store, ¬es_ref, &other).expect("read"),
1319 Some(blob_b)
1320 );
1321 let _ = fs::remove_dir_all(&dir);
1322 }
1323
1324 #[test]
1325 fn remove_notes_for_noop_when_missing() {
1326 let dir = unique_temp_dir("remove-noop");
1327 fs::create_dir_all(&dir).expect("create temp dir");
1328 let (git_dir, target) = init_repo_with_commit(&dir);
1329 let format = ObjectFormat::Sha1;
1330 let store = FileRefStore::new(&git_dir, format);
1331 let notes_ref = heddle_notes_ref();
1332 let identity = test_identity();
1333 let missing =
1334 ObjectId::from_hex(format, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee").expect("oid");
1335
1336 let absent = remove_notes_for(
1337 &git_dir,
1338 format,
1339 &store,
1340 ¬es_ref,
1341 &[target],
1342 "heddle: retract",
1343 &identity,
1344 None,
1345 )
1346 .expect("remove absent ref");
1347 assert_eq!(absent, RemoveNoteOutcome::Unchanged);
1348
1349 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1350 let blob = write_blob(&mut db, br#"{"x":1}"#).expect("blob");
1351 upsert_note_for(
1352 &git_dir,
1353 format,
1354 &store,
1355 ¬es_ref,
1356 &target,
1357 blob,
1358 "heddle: export",
1359 &identity,
1360 None,
1361 )
1362 .expect("seed");
1363 let head = read_notes_head(&store, ¬es_ref).expect("head");
1364
1365 let noop = remove_notes_for(
1366 &git_dir,
1367 format,
1368 &store,
1369 ¬es_ref,
1370 &[missing],
1371 "heddle: retract",
1372 &identity,
1373 Some(RefTarget::Direct(head)),
1374 )
1375 .expect("remove missing oid");
1376 assert_eq!(noop, RemoveNoteOutcome::Unchanged);
1377 assert_eq!(read_notes_head(&store, ¬es_ref), Some(head));
1378 let _ = fs::remove_dir_all(&dir);
1379 }
1380
1381 #[test]
1382 fn remove_notes_for_batch_single_commit() {
1383 let dir = unique_temp_dir("remove-batch");
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 notes_ref = heddle_notes_ref();
1389 let identity = test_identity();
1390 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1391 let first =
1392 ObjectId::from_hex(format, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("oid");
1393 let second =
1394 ObjectId::from_hex(format, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").expect("oid");
1395 let third =
1396 ObjectId::from_hex(format, "cccccccccccccccccccccccccccccccccccccccc").expect("oid");
1397 let blob_a = write_blob(&mut db, b"a\n").expect("blob");
1398 let blob_b = write_blob(&mut db, b"b\n").expect("blob");
1399 let blob_c = write_blob(&mut db, b"c\n").expect("blob");
1400
1401 write_notes(
1402 &git_dir,
1403 format,
1404 &store,
1405 ¬es_ref,
1406 &[
1407 Note {
1408 annotated: first,
1409 blob: blob_a,
1410 },
1411 Note {
1412 annotated: second,
1413 blob: blob_b,
1414 },
1415 Note {
1416 annotated: third,
1417 blob: blob_c,
1418 },
1419 ],
1420 "seed",
1421 &identity,
1422 None,
1423 )
1424 .expect("seed");
1425 let head = read_notes_head(&store, ¬es_ref).expect("head");
1426
1427 let RemoveNoteOutcome::Removed { notes_commit } = remove_notes_for(
1428 &git_dir,
1429 format,
1430 &store,
1431 ¬es_ref,
1432 &[first, second],
1433 "heddle: retract",
1434 &identity,
1435 Some(RefTarget::Direct(head)),
1436 )
1437 .expect("batch remove") else {
1438 panic!("expected removal");
1439 };
1440
1441 let db = FileObjectDatabase::from_git_dir(&git_dir, format);
1442 let commit = db.read_object(¬es_commit).expect("read commit");
1443 let commit = Commit::parse_ref(format, &commit.body).expect("parse");
1444 assert_eq!(commit.parents.len(), 1);
1445 assert_eq!(commit.parents[0], head);
1446 assert_eq!(
1447 list_notes(&git_dir, format, &store, ¬es_ref)
1448 .expect("list")
1449 .len(),
1450 1
1451 );
1452 let _ = fs::remove_dir_all(&dir);
1453 }
1454
1455 #[test]
1456 fn incremental_ops_read_fanout_legacy() {
1457 let dir = unique_temp_dir("incremental-fanout");
1458 fs::create_dir_all(&dir).expect("create temp dir");
1459 let (git_dir, target) = init_repo_with_commit(&dir);
1460 let format = ObjectFormat::Sha1;
1461 let store = FileRefStore::new(&git_dir, format);
1462 let notes_ref = heddle_notes_ref();
1463 let identity = test_identity();
1464 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1465 let blob = write_blob(&mut db, br#"{"legacy":true}"#).expect("blob");
1466
1467 install_fanout_note(&git_dir, &store, ¬es_ref, &target, blob, &identity);
1468 let head = read_notes_head(&store, ¬es_ref).expect("head");
1469 let new_blob = write_blob(&mut db, br#"{"legacy":false}"#).expect("new blob");
1470
1471 upsert_note_for(
1472 &git_dir,
1473 format,
1474 &store,
1475 ¬es_ref,
1476 &target,
1477 new_blob,
1478 "heddle: export",
1479 &identity,
1480 Some(RefTarget::Direct(head)),
1481 )
1482 .expect("upsert fanout");
1483
1484 assert_eq!(
1485 read_note_for(&git_dir, format, &store, ¬es_ref, &target).expect("read"),
1486 Some(new_blob)
1487 );
1488 let _ = fs::remove_dir_all(&dir);
1489 }
1490
1491 #[test]
1492 fn incremental_ops_ff_chain() {
1493 let dir = unique_temp_dir("incremental-ff");
1494 fs::create_dir_all(&dir).expect("create temp dir");
1495 let (git_dir, target) = init_repo_with_commit(&dir);
1496 let format = ObjectFormat::Sha1;
1497 let store = FileRefStore::new(&git_dir, format);
1498 let notes_ref = heddle_notes_ref();
1499 let identity = test_identity();
1500 let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1501 let other =
1502 ObjectId::from_hex(format, "ffffffffffffffffffffffffffffffffffffffff").expect("oid");
1503 let blob_a = write_blob(&mut db, br#"{"first":true}"#).expect("blob a");
1504 let blob_b = write_blob(&mut db, br#"{"second":true}"#).expect("blob b");
1505
1506 let UpsertNoteOutcome::Updated {
1507 notes_commit: first_commit,
1508 } = upsert_note_for(
1509 &git_dir,
1510 format,
1511 &store,
1512 ¬es_ref,
1513 &target,
1514 blob_a,
1515 "heddle: export",
1516 &identity,
1517 None,
1518 )
1519 .expect("first upsert")
1520 else {
1521 panic!("expected update");
1522 };
1523
1524 let UpsertNoteOutcome::Updated {
1525 notes_commit: second_commit,
1526 } = upsert_note_for(
1527 &git_dir,
1528 format,
1529 &store,
1530 ¬es_ref,
1531 &other,
1532 blob_b,
1533 "heddle: export",
1534 &identity,
1535 Some(RefTarget::Direct(first_commit)),
1536 )
1537 .expect("second upsert")
1538 else {
1539 panic!("expected update");
1540 };
1541
1542 let db = FileObjectDatabase::from_git_dir(&git_dir, format);
1543 let object = db.read_object(&second_commit).expect("read commit");
1544 let commit = Commit::parse_ref(format, &object.body).expect("parse");
1545 assert_eq!(commit.parents, vec![first_commit]);
1546 let _ = fs::remove_dir_all(&dir);
1547 }
1548}