Skip to main content

sley_notes/
lib.rs

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