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