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, TreeBuilder, 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    // git appends a "Conflicts:" section listing every conflicting object to the
506    // partial merge commit's message (`merge_one_change_manual`); `--commit`
507    // later reuses that message verbatim, so the finalized merge records which
508    // notes conflicted.
509    let mut commit_message = message.as_bytes().to_vec();
510    if !conflicts.is_empty() {
511        commit_message.extend_from_slice(b"\n\nConflicts:\n");
512        for conflict in &conflicts {
513            commit_message
514                .extend_from_slice(format!("\t{}\n", conflict.annotated.to_hex()).as_bytes());
515        }
516    }
517    let result = commit_notes_update_with_parents(
518        git_dir,
519        format,
520        store,
521        local_ref,
522        &notes,
523        &commit_message,
524        identity,
525        &parents,
526        Some(RefTarget::Direct(local_oid)),
527        conflicts.is_empty(),
528    )?;
529
530    if conflicts.is_empty() {
531        Ok(NotesMergeOutcome::Merged { result })
532    } else {
533        Ok(NotesMergeOutcome::Conflicted {
534            partial: result,
535            conflicts,
536        })
537    }
538}
539
540/// Finalize a previous conflicting notes merge by adding resolved worktree
541/// entries to the partial notes tree and creating the final merge commit.
542#[allow(clippy::too_many_arguments)]
543pub fn finalize_notes_merge(
544    git_dir: &Path,
545    format: ObjectFormat,
546    store: &FileRefStore,
547    notes_ref: &NotesRef,
548    partial_commit: ObjectId,
549    resolved: &[(ObjectId, Vec<u8>)],
550    identity: &NotesCommitIdentity,
551) -> Result<ObjectId> {
552    let db = FileObjectDatabase::from_git_dir(git_dir, format);
553    let partial = read_commit(&db, format, &partial_commit)?;
554    let mut notes = notes_map_from_tree(&db, format, partial.tree)?;
555    let writable = FileObjectDatabase::from_git_dir(git_dir, format);
556    for (annotated, body) in resolved {
557        let blob = writable.write_object(EncodedObject::new(ObjectType::Blob, body.clone()))?;
558        notes.insert(*annotated, blob);
559    }
560    let expected = partial.parents.first().copied().map(RefTarget::Direct);
561    commit_notes_update_with_parents(
562        git_dir,
563        format,
564        store,
565        notes_ref,
566        &notes_vec_from_map(notes),
567        &partial.message,
568        identity,
569        &partial.parents,
570        expected,
571        true,
572    )
573}
574
575/// Incrementally upsert a single note, reading any fanout layout and writing a
576/// flat sorted tree. Returns [`UpsertNoteOutcome::Unchanged`] when `annotated`
577/// already maps to `blob`.
578#[allow(clippy::too_many_arguments)]
579pub fn upsert_note_for(
580    git_dir: &Path,
581    format: ObjectFormat,
582    store: &FileRefStore,
583    notes_ref: &NotesRef,
584    annotated: &ObjectId,
585    blob: ObjectId,
586    message: &str,
587    identity: &NotesCommitIdentity,
588    ref_expected: Option<RefTarget>,
589) -> Result<UpsertNoteOutcome> {
590    if let Some(existing) = read_note_for(git_dir, format, store, notes_ref, annotated)?
591        && existing == blob
592    {
593        return Ok(UpsertNoteOutcome::Unchanged);
594    }
595    let mut notes = list_notes(git_dir, format, store, notes_ref)?;
596    upsert_note(&mut notes, annotated, blob);
597    let notes_commit = commit_notes_update(
598        git_dir,
599        format,
600        store,
601        notes_ref,
602        &notes,
603        message,
604        identity,
605        ref_expected,
606    )?;
607    Ok(UpsertNoteOutcome::Updated { notes_commit })
608}
609
610/// Write `body` as a blob, then call [`upsert_note_for`].
611#[allow(clippy::too_many_arguments)]
612pub fn upsert_note_bytes_for(
613    git_dir: &Path,
614    format: ObjectFormat,
615    store: &FileRefStore,
616    notes_ref: &NotesRef,
617    annotated: &ObjectId,
618    body: &[u8],
619    message: &str,
620    identity: &NotesCommitIdentity,
621    ref_expected: Option<RefTarget>,
622) -> Result<UpsertNoteOutcome> {
623    let db = FileObjectDatabase::from_git_dir(git_dir, format);
624    let blob = db.write_object(EncodedObject::new(ObjectType::Blob, body.to_vec()))?;
625    upsert_note_for(
626        git_dir,
627        format,
628        store,
629        notes_ref,
630        annotated,
631        blob,
632        message,
633        identity,
634        ref_expected,
635    )
636}
637
638/// Remove the note for a single annotated object, if present.
639#[allow(clippy::too_many_arguments)]
640pub fn remove_note_for(
641    git_dir: &Path,
642    format: ObjectFormat,
643    store: &FileRefStore,
644    notes_ref: &NotesRef,
645    annotated: &ObjectId,
646    message: &str,
647    identity: &NotesCommitIdentity,
648    ref_expected: Option<RefTarget>,
649) -> Result<RemoveNoteOutcome> {
650    remove_notes_for(
651        git_dir,
652        format,
653        store,
654        notes_ref,
655        std::slice::from_ref(annotated),
656        message,
657        identity,
658        ref_expected,
659    )
660}
661
662/// Remove notes for `annotated` in a single fast-forward commit when any are
663/// present. Returns [`RemoveNoteOutcome::Unchanged`] when the ref is absent or
664/// none of the oids have notes.
665#[allow(clippy::too_many_arguments)]
666pub fn remove_notes_for(
667    git_dir: &Path,
668    format: ObjectFormat,
669    store: &FileRefStore,
670    notes_ref: &NotesRef,
671    annotated: &[ObjectId],
672    message: &str,
673    identity: &NotesCommitIdentity,
674    ref_expected: Option<RefTarget>,
675) -> Result<RemoveNoteOutcome> {
676    if annotated.is_empty() || notes_head_oid(store, notes_ref)?.is_none() {
677        return Ok(RemoveNoteOutcome::Unchanged);
678    }
679    let targets: HashSet<_> = annotated.iter().collect();
680    let mut notes = list_notes(git_dir, format, store, notes_ref)?;
681    let before = notes.len();
682    notes.retain(|note| !targets.contains(&note.annotated));
683    if notes.len() == before {
684        return Ok(RemoveNoteOutcome::Unchanged);
685    }
686    let notes_commit = commit_notes_update(
687        git_dir,
688        format,
689        store,
690        notes_ref,
691        &notes,
692        message,
693        identity,
694        ref_expected,
695    )?;
696    Ok(RemoveNoteOutcome::Removed { notes_commit })
697}
698
699/// Replace (or insert) the note for `annotated` inside an in-memory note list.
700pub fn upsert_note(notes: &mut Vec<Note>, annotated: &ObjectId, blob: ObjectId) {
701    let target_hex = annotated.to_hex();
702    if let Some(existing) = notes
703        .iter_mut()
704        .find(|entry| entry.annotated.to_hex() == target_hex)
705    {
706        existing.blob = blob;
707    } else {
708        notes.push(Note {
709            annotated: *annotated,
710            blob,
711        });
712    }
713}
714
715/// Remove the note for `annotated` from an in-memory note list, if present.
716pub fn remove_note(notes: &mut Vec<Note>, annotated: &ObjectId) {
717    let target_hex = annotated.to_hex();
718    notes.retain(|entry| entry.annotated.to_hex() != target_hex);
719}
720
721/// Peel `notes_ref` to its root tree oid. Returns `None` when the ref is absent.
722pub fn notes_tree_oid(
723    git_dir: &Path,
724    format: ObjectFormat,
725    store: &FileRefStore,
726    notes_ref: &NotesRef,
727) -> Result<Option<ObjectId>> {
728    let Some(target) = store.read_ref(notes_ref.as_str())? else {
729        return Ok(None);
730    };
731    let commit_oid = match target {
732        RefTarget::Direct(oid) => oid,
733        RefTarget::Symbolic(name) => match store.read_ref(&name)? {
734            Some(RefTarget::Direct(oid)) => oid,
735            _ => return Ok(None),
736        },
737    };
738    let db = FileObjectDatabase::from_git_dir(git_dir, format);
739    let object = db.read_object(&commit_oid)?;
740    match object.object_type {
741        ObjectType::Commit => Ok(Some(Commit::parse_ref(format, &object.body)?.tree)),
742        ObjectType::Tree => Ok(Some(commit_oid)),
743        _ => Ok(None),
744    }
745}
746
747fn load_hex_tree_entries(
748    db: &FileObjectDatabase,
749    format: ObjectFormat,
750    tree_oid: &ObjectId,
751) -> Result<Vec<(String, u32, ObjectId)>> {
752    let object = db.read_object(tree_oid)?;
753    if object.object_type != ObjectType::Tree {
754        return Ok(Vec::new());
755    }
756    let mut out = Vec::new();
757    for entry in TreeEntries::new(format, &object.body) {
758        let entry = entry?;
759        let Ok(name) = std::str::from_utf8(entry.name) else {
760            continue;
761        };
762        if !is_hex_name(name) {
763            continue;
764        }
765        out.push((name.to_string(), entry.mode, entry.oid));
766    }
767    Ok(out)
768}
769
770fn lookup_note_for(
771    db: &FileObjectDatabase,
772    format: ObjectFormat,
773    tree_oid: &ObjectId,
774    prefix: &str,
775    target_hex: &str,
776) -> Result<Option<ObjectId>> {
777    for (name, mode, oid) in load_hex_tree_entries(db, format, tree_oid)? {
778        let mut hex = prefix.to_string();
779        hex.push_str(&name);
780        if tree_entry_object_type(mode) == ObjectType::Tree {
781            if !target_hex.starts_with(&hex) {
782                continue;
783            }
784            if let Some(blob) = lookup_note_for(db, format, &oid, &hex, target_hex)? {
785                return Ok(Some(blob));
786            }
787        } else if hex == target_hex {
788            return Ok(Some(oid));
789        }
790    }
791    Ok(None)
792}
793
794fn is_hex_name(name: &str) -> bool {
795    !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_hexdigit())
796}
797
798fn expand_notes_ref(name: &str) -> String {
799    if name.starts_with("refs/notes/") {
800        name.to_string()
801    } else {
802        format!("refs/notes/{name}")
803    }
804}
805
806/// Include-aware read of `<git_dir>/config` (resolves `include`/`includeIf` and
807/// layers inherited `GIT_CONFIG_*` overrides), shared with the rest of the
808/// library via [`sley_config::read_repo_config`]. Used as the fallback when a
809/// caller did not pass an already-resolved effective config to
810/// [`resolve_notes_ref_with_config`].
811fn read_repo_config(git_dir: &Path) -> Result<GitConfig> {
812    sley_config::read_repo_config(git_dir, None)
813}
814
815fn read_commit(db: &FileObjectDatabase, format: ObjectFormat, oid: &ObjectId) -> Result<Commit> {
816    let object = db.read_object(oid)?;
817    if object.object_type != ObjectType::Commit {
818        return Err(GitError::InvalidFormat(format!(
819            "{} is not a commit",
820            oid.to_hex()
821        )));
822    }
823    Commit::parse(format, &object.body)
824}
825
826fn commit_tree_oid(
827    db: &FileObjectDatabase,
828    format: ObjectFormat,
829    oid: &ObjectId,
830) -> Result<ObjectId> {
831    Ok(read_commit(db, format, oid)?.tree)
832}
833
834fn merge_base_oids(
835    db: &FileObjectDatabase,
836    format: ObjectFormat,
837    left: &ObjectId,
838    right: &ObjectId,
839) -> Result<Vec<ObjectId>> {
840    let left_depths = ancestor_depths(db, format, left)?;
841    let right_depths = ancestor_depths(db, format, right)?;
842    let candidates: Vec<ObjectId> = left_depths
843        .keys()
844        .filter(|oid| right_depths.contains_key(*oid))
845        .copied()
846        .collect();
847    let mut bases: Vec<ObjectId> = candidates
848        .iter()
849        .filter(|candidate| {
850            !candidates.iter().any(|other| {
851                other != *candidate
852                    && depth_lt(&left_depths, other, candidate)
853                    && depth_lt(&right_depths, other, candidate)
854            })
855        })
856        .copied()
857        .collect();
858    bases.sort_by_key(|oid| oid.to_hex());
859    Ok(bases)
860}
861
862fn ancestor_depths(
863    db: &FileObjectDatabase,
864    format: ObjectFormat,
865    start: &ObjectId,
866) -> Result<HashMap<ObjectId, usize>> {
867    let mut depths = HashMap::new();
868    let mut pending = VecDeque::from([(*start, 0usize)]);
869    while let Some((oid, depth)) = pending.pop_front() {
870        if depths.get(&oid).is_some_and(|seen| *seen <= depth) {
871            continue;
872        }
873        depths.insert(oid, depth);
874        for parent in read_commit(db, format, &oid)?.parents {
875            pending.push_back((parent, depth + 1));
876        }
877    }
878    Ok(depths)
879}
880
881fn depth_lt(depths: &HashMap<ObjectId, usize>, left: &ObjectId, right: &ObjectId) -> bool {
882    match (depths.get(left), depths.get(right)) {
883        (Some(left), Some(right)) => left < right,
884        _ => false,
885    }
886}
887
888fn notes_head_oid(store: &FileRefStore, notes_ref: &NotesRef) -> Result<Option<ObjectId>> {
889    Ok(match store.read_ref(notes_ref.as_str())? {
890        Some(RefTarget::Direct(oid)) => Some(oid),
891        _ => None,
892    })
893}
894
895fn notes_map_from_tree(
896    db: &FileObjectDatabase,
897    format: ObjectFormat,
898    tree_oid: ObjectId,
899) -> Result<BTreeMap<ObjectId, ObjectId>> {
900    let mut notes = BTreeMap::new();
901    if tree_oid == ObjectId::empty_tree(format) {
902        return Ok(notes);
903    }
904    collect_notes_from_tree(db, format, tree_oid, "", &mut notes)?;
905    Ok(notes)
906}
907
908fn collect_notes_from_tree(
909    db: &FileObjectDatabase,
910    format: ObjectFormat,
911    tree_oid: ObjectId,
912    prefix: &str,
913    out: &mut BTreeMap<ObjectId, ObjectId>,
914) -> Result<()> {
915    for (name, mode, oid) in load_hex_tree_entries(db, format, &tree_oid)? {
916        let mut hex = prefix.to_string();
917        hex.push_str(&name);
918        if tree_entry_object_type(mode) == ObjectType::Tree {
919            collect_notes_from_tree(db, format, oid, &hex, out)?;
920        } else if hex.len() == format.hex_len()
921            && let Ok(annotated) = ObjectId::from_hex(format, &hex)
922        {
923            out.insert(annotated, oid);
924        }
925    }
926    Ok(())
927}
928
929fn notes_vec_from_map(notes: BTreeMap<ObjectId, ObjectId>) -> Vec<Note> {
930    notes
931        .into_iter()
932        .map(|(annotated, blob)| Note { annotated, blob })
933        .collect()
934}
935
936fn set_note_option(
937    notes: &mut BTreeMap<ObjectId, ObjectId>,
938    annotated: ObjectId,
939    blob: Option<ObjectId>,
940) {
941    match blob {
942        Some(blob) => {
943            notes.insert(annotated, blob);
944        }
945        None => {
946            notes.remove(&annotated);
947        }
948    }
949}
950
951enum NoteBlobCombine {
952    Union,
953    CatSortUniq,
954}
955
956fn combine_note_blobs(
957    git_dir: &Path,
958    db: &FileObjectDatabase,
959    format: ObjectFormat,
960    local: Option<ObjectId>,
961    remote: Option<ObjectId>,
962    mode: NoteBlobCombine,
963) -> Result<Option<ObjectId>> {
964    match mode {
965        NoteBlobCombine::Union => combine_note_blobs_union(git_dir, db, format, local, remote),
966        NoteBlobCombine::CatSortUniq => {
967            combine_note_blobs_cat_sort_uniq(git_dir, db, format, local, remote)
968        }
969    }
970}
971
972fn read_blob_bytes(db: &FileObjectDatabase, oid: &ObjectId) -> Result<Option<Vec<u8>>> {
973    let object = db.read_object(oid)?;
974    if object.object_type != ObjectType::Blob || object.body.is_empty() {
975        return Ok(None);
976    }
977    Ok(Some(object.body.clone()))
978}
979
980fn combine_note_blobs_union(
981    git_dir: &Path,
982    db: &FileObjectDatabase,
983    format: ObjectFormat,
984    local: Option<ObjectId>,
985    remote: Option<ObjectId>,
986) -> Result<Option<ObjectId>> {
987    let Some(remote_oid) = remote else {
988        return Ok(local);
989    };
990    let Some(remote_body) = read_blob_bytes(db, &remote_oid)? else {
991        return Ok(local);
992    };
993    let Some(local_oid) = local else {
994        return Ok(Some(remote_oid));
995    };
996    let Some(mut local_body) = read_blob_bytes(db, &local_oid)? else {
997        return Ok(Some(remote_oid));
998    };
999    if local_body.last() == Some(&b'\n') {
1000        local_body.pop();
1001    }
1002    local_body.extend_from_slice(b"\n\n");
1003    local_body.extend_from_slice(&remote_body);
1004    let writable = FileObjectDatabase::from_git_dir(git_dir, format);
1005    writable
1006        .write_object(EncodedObject::new(ObjectType::Blob, local_body))
1007        .map(Some)
1008}
1009
1010fn combine_note_blobs_cat_sort_uniq(
1011    git_dir: &Path,
1012    db: &FileObjectDatabase,
1013    format: ObjectFormat,
1014    local: Option<ObjectId>,
1015    remote: Option<ObjectId>,
1016) -> Result<Option<ObjectId>> {
1017    let mut lines: Vec<Vec<u8>> = Vec::new();
1018    for oid in [local, remote].into_iter().flatten() {
1019        if let Some(body) = read_blob_bytes(db, &oid)? {
1020            lines.extend(body.split(|byte| *byte == b'\n').map(|line| line.to_vec()));
1021        }
1022    }
1023    lines.retain(|line| !line.is_empty());
1024    if lines.is_empty() {
1025        return Ok(None);
1026    }
1027    lines.sort();
1028    lines.dedup();
1029    let mut body = Vec::new();
1030    for line in lines {
1031        body.extend_from_slice(&line);
1032        body.push(b'\n');
1033    }
1034    let writable = FileObjectDatabase::from_git_dir(git_dir, format);
1035    writable
1036        .write_object(EncodedObject::new(ObjectType::Blob, body))
1037        .map(Some)
1038}
1039
1040#[allow(clippy::too_many_arguments)]
1041fn commit_notes_update(
1042    git_dir: &Path,
1043    format: ObjectFormat,
1044    store: &FileRefStore,
1045    notes_ref: &NotesRef,
1046    notes: &[Note],
1047    message: &str,
1048    identity: &NotesCommitIdentity,
1049    ref_expected: Option<RefTarget>,
1050) -> Result<ObjectId> {
1051    let parent = notes_head_oid(store, notes_ref)?;
1052    let parents = parent.iter().cloned().collect::<Vec<_>>();
1053    commit_notes_update_with_parents(
1054        git_dir,
1055        format,
1056        store,
1057        notes_ref,
1058        notes,
1059        format!("{message}\n").as_bytes(),
1060        identity,
1061        &parents,
1062        ref_expected,
1063        true,
1064    )
1065}
1066
1067#[allow(clippy::too_many_arguments)]
1068fn commit_notes_update_with_parents(
1069    git_dir: &Path,
1070    format: ObjectFormat,
1071    store: &FileRefStore,
1072    notes_ref: &NotesRef,
1073    notes: &[Note],
1074    message: &[u8],
1075    identity: &NotesCommitIdentity,
1076    parents: &[ObjectId],
1077    ref_expected: Option<RefTarget>,
1078    update_ref: bool,
1079) -> Result<ObjectId> {
1080    let mut db = FileObjectDatabase::from_git_dir(git_dir, format);
1081    // Preserve any non-note entries carried by the base notes commit. git keeps a
1082    // sorted "non_note" list while loading a notes tree and weaves it back on
1083    // write so arbitrary blobs/dirs living in a notes tree survive note edits
1084    // (t3304). The base is the first parent — the commit whose tree was read,
1085    // mutated, and is being rewritten here.
1086    let non_notes = match parents.first() {
1087        Some(parent) => collect_non_notes_from_commit(&db, format, parent)?,
1088        None => Vec::new(),
1089    };
1090    let tree_oid = write_notes_tree_preserving(&mut db, notes, &non_notes)?;
1091
1092    let commit_oid = create_commit(
1093        &mut db,
1094        CommitCreate {
1095            tree: tree_oid,
1096            parents: parents.to_vec(),
1097            author: identity.author.clone(),
1098            committer: identity.committer.clone(),
1099            message: message.to_vec(),
1100            encoding: None,
1101            signature: None,
1102        },
1103    )?;
1104
1105    if !update_ref {
1106        return Ok(commit_oid);
1107    }
1108    let old_oid = parents.first().copied().unwrap_or(zero_oid(format)?);
1109    let mut tx = store.transaction();
1110    let reflog_message = reflog_message_from_commit_message(message);
1111    tx.update(RefUpdate {
1112        name: notes_ref.as_str().to_string(),
1113        expected: ref_expected,
1114        new: RefTarget::Direct(commit_oid),
1115        reflog: Some(ReflogEntry {
1116            old_oid,
1117            new_oid: commit_oid,
1118            committer: identity.committer.clone(),
1119            // git prefixes the notes reflog message with "notes: " (the commit
1120            // message itself is left unprefixed).
1121            message: reflog_message,
1122        }),
1123    });
1124    tx.commit()?;
1125    Ok(commit_oid)
1126}
1127
1128fn update_notes_ref_to_commit(
1129    git_dir: &Path,
1130    format: ObjectFormat,
1131    store: &FileRefStore,
1132    notes_ref: &NotesRef,
1133    old: Option<ObjectId>,
1134    new: ObjectId,
1135    message: &str,
1136    identity: &NotesCommitIdentity,
1137) -> Result<()> {
1138    let old_oid = old.unwrap_or(zero_oid(format)?);
1139    let mut tx = store.transaction();
1140    tx.update(RefUpdate {
1141        name: notes_ref.as_str().to_string(),
1142        expected: old.map(RefTarget::Direct),
1143        new: RefTarget::Direct(new),
1144        reflog: Some(ReflogEntry {
1145            old_oid,
1146            new_oid: new,
1147            committer: identity.committer.clone(),
1148            message: format!("notes: {message}").into_bytes(),
1149        }),
1150    });
1151    let _ = git_dir;
1152    tx.commit()
1153}
1154
1155fn reflog_message_from_commit_message(message: &[u8]) -> Vec<u8> {
1156    let subject = message
1157        .split(|byte| *byte == b'\n')
1158        .next()
1159        .unwrap_or(message);
1160    let mut out = b"notes: ".to_vec();
1161    out.extend_from_slice(subject);
1162    out
1163}
1164
1165fn write_notes_tree(db: &mut FileObjectDatabase, notes: &[Note]) -> Result<ObjectId> {
1166    if notes.len() >= 256 {
1167        write_fanout_notes_tree(db, notes)
1168    } else {
1169        write_flat_notes_tree(db, notes)
1170    }
1171}
1172
1173fn write_flat_notes_tree(db: &mut FileObjectDatabase, notes: &[Note]) -> Result<ObjectId> {
1174    let mut entries: Vec<TreeEntry> = notes
1175        .iter()
1176        .map(|note| TreeEntry {
1177            mode: 0o100644,
1178            name: BString::from(note.annotated.to_hex().as_bytes()),
1179            oid: note.blob,
1180        })
1181        .collect();
1182    entries.sort_by(|left, right| left.name.cmp(&right.name));
1183    db.write_object(EncodedObject::new(
1184        ObjectType::Tree,
1185        Tree { entries }.write(),
1186    ))
1187}
1188
1189fn write_fanout_notes_tree(db: &mut FileObjectDatabase, notes: &[Note]) -> Result<ObjectId> {
1190    let mut groups: BTreeMap<String, Vec<TreeEntry>> = BTreeMap::new();
1191    for note in notes {
1192        let hex = note.annotated.to_hex();
1193        let (prefix, suffix) = hex.split_at(2);
1194        groups
1195            .entry(prefix.to_string())
1196            .or_default()
1197            .push(TreeEntry {
1198                mode: 0o100644,
1199                name: BString::from(suffix.as_bytes()),
1200                oid: note.blob,
1201            });
1202    }
1203
1204    let mut root_entries = Vec::new();
1205    for (prefix, mut entries) in groups {
1206        entries.sort_by(|left, right| left.name.cmp(&right.name));
1207        let subtree_oid = db.write_object(EncodedObject::new(
1208            ObjectType::Tree,
1209            Tree { entries }.write(),
1210        ))?;
1211        root_entries.push(TreeEntry {
1212            mode: 0o040000,
1213            name: BString::from(prefix.as_bytes()),
1214            oid: subtree_oid,
1215        });
1216    }
1217    root_entries.sort_by(|left, right| left.name.cmp(&right.name));
1218    db.write_object(EncodedObject::new(
1219        ObjectType::Tree,
1220        Tree {
1221            entries: root_entries,
1222        }
1223        .write(),
1224    ))
1225}
1226
1227/// A flattened tree entry: full slash-separated path, mode, and object id.
1228type PathEntry = (Vec<u8>, u32, ObjectId);
1229
1230/// A tree entry inside a notes tree that is *not* a note: an arbitrary blob or
1231/// directory whose path does not spell out an object id under git's fanout
1232/// scheme. git records these while loading a notes tree and writes them back
1233/// untouched (`struct non_note` in upstream `notes.c`).
1234#[derive(Debug, Clone)]
1235struct NonNote {
1236    /// Full slash-separated path from the notes-tree root (e.g. `de/adbeef`).
1237    path: Vec<u8>,
1238    mode: u32,
1239    oid: ObjectId,
1240}
1241
1242/// Read the non-note entries from a notes commit's tree, mirroring git's
1243/// `load_subtree`: an entry is a note iff its name fills the remaining hex
1244/// nibbles of an object id and is a hex blob; a two-hex directory is a fanout
1245/// level to recurse into; everything else is a non-note recorded at its full
1246/// path (a non-fanout directory is kept wholesale, not descended into).
1247fn collect_non_notes_from_commit(
1248    db: &FileObjectDatabase,
1249    format: ObjectFormat,
1250    commit_oid: &ObjectId,
1251) -> Result<Vec<NonNote>> {
1252    let object = db.read_object(commit_oid)?;
1253    if object.object_type != ObjectType::Commit {
1254        return Ok(Vec::new());
1255    }
1256    let commit = Commit::parse(format, &object.body)?;
1257    let mut out = Vec::new();
1258    collect_non_notes_rec(db, format, &commit.tree, &[], 0, &mut out)?;
1259    Ok(out)
1260}
1261
1262fn collect_non_notes_rec(
1263    db: &FileObjectDatabase,
1264    format: ObjectFormat,
1265    tree_oid: &ObjectId,
1266    prefix: &[u8],
1267    consumed_hex: usize,
1268    out: &mut Vec<NonNote>,
1269) -> Result<()> {
1270    let object = db.read_object(tree_oid)?;
1271    if object.object_type != ObjectType::Tree {
1272        return Ok(());
1273    }
1274    let hex_len = format.hex_len();
1275    for entry in TreeEntries::new(format, &object.body) {
1276        let entry = entry?;
1277        let name = entry.name;
1278        let name_len = name.len();
1279        let is_tree = tree_entry_object_type(entry.mode) == ObjectType::Tree;
1280        let is_hex = !name.is_empty() && name.iter().all(u8::is_ascii_hexdigit);
1281
1282        if consumed_hex < hex_len && name_len == hex_len - consumed_hex {
1283            // Slot for the remainder of an object id: a hex blob here is a note.
1284            if !is_tree && is_hex {
1285                continue;
1286            }
1287        } else if name_len == 2 && is_tree && is_hex {
1288            // A two-hex directory is a fanout level — descend, consuming 2 nibbles.
1289            let mut child_prefix = prefix.to_vec();
1290            child_prefix.extend_from_slice(name);
1291            child_prefix.push(b'/');
1292            collect_non_notes_rec(db, format, &entry.oid, &child_prefix, consumed_hex + 2, out)?;
1293            continue;
1294        }
1295
1296        // Anything else is a non-note: keep it at its full path, mode and oid.
1297        let mut full = prefix.to_vec();
1298        full.extend_from_slice(name);
1299        out.push(NonNote {
1300            path: full,
1301            mode: entry.mode,
1302            oid: entry.oid,
1303        });
1304    }
1305    Ok(())
1306}
1307
1308/// Write the notes tree, weaving in preserved non-note entries. With no
1309/// non-notes this defers to the byte-identical flat/fanout writers so every
1310/// existing notes tree is unaffected.
1311fn write_notes_tree_preserving(
1312    db: &mut FileObjectDatabase,
1313    notes: &[Note],
1314    non_notes: &[NonNote],
1315) -> Result<ObjectId> {
1316    if non_notes.is_empty() {
1317        return write_notes_tree(db, notes);
1318    }
1319    write_woven_notes_tree(db, notes, non_notes)
1320}
1321
1322/// The on-disk path for each note under the same flat-vs-fanout rule as
1323/// [`write_notes_tree`] (flat below 256 notes, one-level 2/38 fanout above).
1324fn note_disk_paths(notes: &[Note]) -> Vec<PathEntry> {
1325    if notes.len() >= 256 {
1326        notes
1327            .iter()
1328            .map(|note| {
1329                let hex = note.annotated.to_hex();
1330                let (prefix, suffix) = hex.split_at(2);
1331                let mut path = prefix.as_bytes().to_vec();
1332                path.push(b'/');
1333                path.extend_from_slice(suffix.as_bytes());
1334                (path, 0o100644u32, note.blob)
1335            })
1336            .collect()
1337    } else {
1338        notes
1339            .iter()
1340            .map(|note| (note.annotated.to_hex().into_bytes(), 0o100644u32, note.blob))
1341            .collect()
1342    }
1343}
1344
1345fn write_woven_notes_tree(
1346    db: &mut FileObjectDatabase,
1347    notes: &[Note],
1348    non_notes: &[NonNote],
1349) -> Result<ObjectId> {
1350    // Notes win on an exact path collision (git prefers the note), so seed the
1351    // map with notes and only fill in a non-note where no note already sits.
1352    let mut paths: BTreeMap<Vec<u8>, (u32, ObjectId)> = BTreeMap::new();
1353    for (path, mode, oid) in note_disk_paths(notes) {
1354        paths.insert(path, (mode, oid));
1355    }
1356    for non_note in non_notes {
1357        paths
1358            .entry(non_note.path.clone())
1359            .or_insert((non_note.mode, non_note.oid));
1360    }
1361    let entries: Vec<PathEntry> = paths
1362        .into_iter()
1363        .map(|(path, (mode, oid))| (path, mode, oid))
1364        .collect();
1365    build_nested_tree(db, &entries)
1366}
1367
1368/// Build (and write) nested tree objects from full slash-separated paths,
1369/// returning the root tree oid. Entries are grouped by first path component;
1370/// each level is emitted in git's canonical order via [`TreeBuilder`].
1371fn build_nested_tree(db: &mut FileObjectDatabase, entries: &[PathEntry]) -> Result<ObjectId> {
1372    let mut builder = TreeBuilder::new();
1373    let mut subdirs: BTreeMap<Vec<u8>, Vec<PathEntry>> = BTreeMap::new();
1374    for (path, mode, oid) in entries {
1375        match path.iter().position(|byte| *byte == b'/') {
1376            None => builder.upsert_raw(path.clone(), *mode, *oid),
1377            Some(slash) => {
1378                let component = path[..slash].to_vec();
1379                let rest = path[slash + 1..].to_vec();
1380                subdirs
1381                    .entry(component)
1382                    .or_default()
1383                    .push((rest, *mode, *oid));
1384            }
1385        }
1386    }
1387    for (component, children) in subdirs {
1388        let subtree_oid = build_nested_tree(db, &children)?;
1389        builder.upsert_raw(component, 0o040000, subtree_oid);
1390    }
1391    db.write_object(EncodedObject::new(
1392        ObjectType::Tree,
1393        builder.build().write(),
1394    ))
1395}
1396
1397fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
1398    ObjectId::from_hex(format, &"0".repeat(format.hex_len()))
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403    use super::*;
1404    use sley_sequencer::format_commit_identity;
1405    use std::fs;
1406    use std::path::{Path, PathBuf};
1407    use std::process::{Command, Stdio};
1408    use std::time::{SystemTime, UNIX_EPOCH};
1409
1410    const NAME: &str = "Tester";
1411    const EMAIL: &str = "tester@example.com";
1412    const DATE: &str = "@1790000000 -0500";
1413
1414    fn unique_temp_dir(name: &str) -> PathBuf {
1415        let nanos = SystemTime::now()
1416            .duration_since(UNIX_EPOCH)
1417            .expect("system time before unix epoch")
1418            .as_nanos();
1419        std::env::temp_dir().join(format!("sley-notes-{name}-{}-{nanos}", std::process::id()))
1420    }
1421
1422    fn git_available() -> bool {
1423        Command::new("git")
1424            .arg("--version")
1425            .stdout(Stdio::null())
1426            .stderr(Stdio::null())
1427            .status()
1428            .map(|status| status.success())
1429            .unwrap_or(false)
1430    }
1431
1432    fn test_identity() -> NotesCommitIdentity {
1433        NotesCommitIdentity {
1434            author: format_commit_identity(NAME, EMAIL, DATE)
1435                .expect("test operation should succeed"),
1436            committer: format_commit_identity(NAME, EMAIL, DATE)
1437                .expect("test operation should succeed"),
1438        }
1439    }
1440
1441    fn git_env(command: &mut Command) -> &mut Command {
1442        command
1443            .env("GIT_AUTHOR_NAME", NAME)
1444            .env("GIT_AUTHOR_EMAIL", EMAIL)
1445            .env("GIT_AUTHOR_DATE", DATE)
1446            .env("GIT_COMMITTER_NAME", NAME)
1447            .env("GIT_COMMITTER_EMAIL", EMAIL)
1448            .env("GIT_COMMITTER_DATE", DATE)
1449    }
1450
1451    fn init_repo_with_commit(root: &Path) -> (PathBuf, ObjectId) {
1452        let mut init = Command::new("git");
1453        git_env(init.current_dir(root).args(["init", "-q"]))
1454            .status()
1455            .expect("git init should succeed");
1456        fs::write(root.join("f.txt"), b"content\n").expect("write worktree file");
1457        let mut add = Command::new("git");
1458        git_env(add.current_dir(root).args(["add", "f.txt"]))
1459            .status()
1460            .expect("git add should succeed");
1461        let mut commit = Command::new("git");
1462        git_env(commit.current_dir(root).args(["commit", "-q", "-m", "c1"]))
1463            .status()
1464            .expect("git commit should succeed");
1465        let git_dir = root.join(".git");
1466        let format = ObjectFormat::Sha1;
1467        let store = FileRefStore::new(&git_dir, format);
1468        let head = store
1469            .read_ref("HEAD")
1470            .expect("read HEAD")
1471            .expect("HEAD should exist");
1472        let oid = match head {
1473            RefTarget::Direct(oid) => oid,
1474            RefTarget::Symbolic(name) => match store.read_ref(&name).expect("read symref") {
1475                Some(RefTarget::Direct(oid)) => oid,
1476                other => panic!("unexpected symref target: {other:?}"),
1477            },
1478        };
1479        (git_dir, oid)
1480    }
1481
1482    fn write_blob(db: &mut FileObjectDatabase, bytes: &[u8]) -> Result<ObjectId> {
1483        db.write_object(EncodedObject::new(ObjectType::Blob, bytes.to_vec()))
1484    }
1485
1486    #[test]
1487    fn notes_ref_expand_qualifies_names() {
1488        assert_eq!(NotesRef::expand("commits").as_str(), "refs/notes/commits");
1489        assert_eq!(
1490            NotesRef::expand("refs/notes/review").as_str(),
1491            "refs/notes/review"
1492        );
1493    }
1494
1495    #[test]
1496    fn read_write_list_round_trip() {
1497        let dir = unique_temp_dir("round-trip");
1498        fs::create_dir_all(&dir).expect("create temp dir");
1499        let (git_dir, target) = init_repo_with_commit(&dir);
1500        let format = ObjectFormat::Sha1;
1501        let store = FileRefStore::new(&git_dir, format);
1502        let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1503        let identity = test_identity();
1504        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1505        let blob = write_blob(&mut db, b"hello note\n").expect("test operation should succeed");
1506
1507        let mut notes = Vec::new();
1508        upsert_note(&mut notes, &target, blob);
1509        write_notes(
1510            &git_dir,
1511            format,
1512            &store,
1513            &notes_ref,
1514            &notes,
1515            "Notes added by test",
1516            &identity,
1517            notes_ref_expected(&store, &notes_ref).expect("ref expected"),
1518        )
1519        .expect("test operation should succeed");
1520
1521        let listed = list_notes(&git_dir, format, &store, &notes_ref)
1522            .expect("test operation should succeed");
1523        assert_eq!(listed.len(), 1);
1524        assert_eq!(listed[0].annotated, target);
1525        assert_eq!(listed[0].blob, blob);
1526
1527        let read_back = read_note(&git_dir, format, &store, &notes_ref, &target)
1528            .expect("test operation should succeed");
1529        assert_eq!(read_back, Some(blob));
1530
1531        let bytes = read_note_bytes(&git_dir, format, &store, &notes_ref, &target)
1532            .expect("test operation should succeed");
1533        assert_eq!(bytes.as_deref(), Some(b"hello note\n" as &[u8]));
1534        let _ = fs::remove_dir_all(&dir);
1535    }
1536
1537    #[test]
1538    fn iter_notes_matches_list_notes() {
1539        let dir = unique_temp_dir("iter-list");
1540        fs::create_dir_all(&dir).expect("create temp dir");
1541        let (git_dir, target) = init_repo_with_commit(&dir);
1542        let format = ObjectFormat::Sha1;
1543        let store = FileRefStore::new(&git_dir, format);
1544        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1545        let blob = write_blob(&mut db, b"iter note\n").expect("blob");
1546        let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1547        write_notes(
1548            &git_dir,
1549            format,
1550            &store,
1551            &notes_ref,
1552            &[Note {
1553                annotated: target,
1554                blob,
1555            }],
1556            "note",
1557            &test_identity(),
1558            notes_ref_expected(&store, &notes_ref).expect("ref expected"),
1559        )
1560        .expect("write notes");
1561
1562        let listed = list_notes(&git_dir, format, &store, &notes_ref).expect("list");
1563        let mut iter_collected = iter_notes(&git_dir, format, &store, &notes_ref)
1564            .expect("iter")
1565            .collect::<Result<Vec<_>>>()
1566            .expect("collect");
1567        iter_collected.sort_by_key(|entry| entry.annotated.to_hex());
1568        assert_eq!(listed, iter_collected);
1569        let _ = fs::remove_dir_all(&dir);
1570    }
1571
1572    #[test]
1573    fn iter_notes_yields_every_note_in_flat_tree() {
1574        let dir = unique_temp_dir("iter-flat-multi");
1575        fs::create_dir_all(&dir).expect("create temp dir");
1576        let (git_dir, _) = init_repo_with_commit(&dir);
1577        let format = ObjectFormat::Sha1;
1578        let store = FileRefStore::new(&git_dir, format);
1579        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1580        let first =
1581            ObjectId::from_hex(format, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("oid");
1582        let second =
1583            ObjectId::from_hex(format, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").expect("oid");
1584        let blob_a = write_blob(&mut db, b"note a\n").expect("blob");
1585        let blob_b = write_blob(&mut db, b"note b\n").expect("blob");
1586        let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1587        write_notes(
1588            &git_dir,
1589            format,
1590            &store,
1591            &notes_ref,
1592            &[
1593                Note {
1594                    annotated: first,
1595                    blob: blob_a,
1596                },
1597                Note {
1598                    annotated: second,
1599                    blob: blob_b,
1600                },
1601            ],
1602            "notes",
1603            &test_identity(),
1604            notes_ref_expected(&store, &notes_ref).expect("ref expected"),
1605        )
1606        .expect("write notes");
1607
1608        let collected = iter_notes(&git_dir, format, &store, &notes_ref)
1609            .expect("iter")
1610            .collect::<Result<Vec<_>>>()
1611            .expect("collect");
1612        assert_eq!(collected.len(), 2);
1613        let _ = fs::remove_dir_all(&dir);
1614    }
1615
1616    #[test]
1617    fn read_note_for_skips_unrelated_fanout_branches() {
1618        let dir = unique_temp_dir("lookup");
1619        fs::create_dir_all(&dir).expect("create temp dir");
1620        let (git_dir, target) = init_repo_with_commit(&dir);
1621        let format = ObjectFormat::Sha1;
1622        let store = FileRefStore::new(&git_dir, format);
1623        let target_hex = target.to_hex();
1624        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1625        let blob = write_blob(&mut db, b"lookup note\n").expect("blob");
1626        let prefix = &target_hex[..2];
1627        let suffix = &target_hex[2..];
1628        let leaf = Tree {
1629            entries: vec![TreeEntry {
1630                mode: 0o100644,
1631                name: BString::from(suffix.as_bytes()),
1632                oid: blob,
1633            }],
1634        };
1635        let leaf_oid = db
1636            .write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
1637            .expect("leaf");
1638        let fanout = Tree {
1639            entries: vec![TreeEntry {
1640                mode: 0o040000,
1641                name: BString::from(prefix.as_bytes()),
1642                oid: leaf_oid,
1643            }],
1644        };
1645        let fanout_oid = db
1646            .write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
1647            .expect("fanout");
1648        let identity = test_identity();
1649        let commit_oid = create_commit(
1650            &mut db,
1651            CommitCreate {
1652                tree: fanout_oid,
1653                parents: Vec::new(),
1654                author: identity.author.clone(),
1655                committer: identity.committer.clone(),
1656                message: b"fanout notes\n".to_vec(),
1657                encoding: None,
1658                signature: None,
1659            },
1660        )
1661        .expect("commit");
1662        let mut tx = store.transaction();
1663        tx.update(RefUpdate {
1664            name: DEFAULT_NOTES_REF.to_string(),
1665            expected: None,
1666            new: RefTarget::Direct(commit_oid),
1667            reflog: None,
1668        });
1669        tx.commit().expect("update ref");
1670        let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1671        let found = read_note_for(&git_dir, format, &store, &notes_ref, &target).expect("lookup");
1672        assert_eq!(found, Some(blob));
1673        let _ = fs::remove_dir_all(&dir);
1674    }
1675
1676    #[test]
1677    fn fanout_tree_is_readable() {
1678        let dir = unique_temp_dir("fanout");
1679        fs::create_dir_all(&dir).expect("create temp dir");
1680        let (git_dir, target) = init_repo_with_commit(&dir);
1681        let format = ObjectFormat::Sha1;
1682        let store = FileRefStore::new(&git_dir, format);
1683        let target_hex = target.to_hex();
1684        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1685        let blob = write_blob(&mut db, b"fanout note\n").expect("test operation should succeed");
1686
1687        // Build a two-level fanout tree: ab/<rest-of-hex> -> blob
1688        let prefix = &target_hex[..2];
1689        let suffix = &target_hex[2..];
1690        let leaf = Tree {
1691            entries: vec![TreeEntry {
1692                mode: 0o100644,
1693                name: BString::from(suffix.as_bytes()),
1694                oid: blob,
1695            }],
1696        };
1697        let leaf_oid = db
1698            .write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
1699            .expect("test operation should succeed");
1700        let fanout = Tree {
1701            entries: vec![TreeEntry {
1702                mode: 0o040000,
1703                name: BString::from(prefix.as_bytes()),
1704                oid: leaf_oid,
1705            }],
1706        };
1707        let fanout_oid = db
1708            .write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
1709            .expect("test operation should succeed");
1710
1711        let identity = test_identity();
1712        let commit_oid = create_commit(
1713            &mut db,
1714            CommitCreate {
1715                tree: fanout_oid,
1716                parents: Vec::new(),
1717                author: identity.author.clone(),
1718                committer: identity.committer.clone(),
1719                message: b"fanout notes\n".to_vec(),
1720                encoding: None,
1721                signature: None,
1722            },
1723        )
1724        .expect("test operation should succeed");
1725        let mut tx = store.transaction();
1726        tx.update(RefUpdate {
1727            name: DEFAULT_NOTES_REF.to_string(),
1728            expected: None,
1729            new: RefTarget::Direct(commit_oid),
1730            reflog: None,
1731        });
1732        tx.commit().expect("test operation should succeed");
1733
1734        let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1735        let read_back = read_note(&git_dir, format, &store, &notes_ref, &target)
1736            .expect("test operation should succeed");
1737        assert_eq!(read_back, Some(blob));
1738        let _ = fs::remove_dir_all(&dir);
1739    }
1740
1741    #[test]
1742    fn note_bytes_match_system_git() {
1743        if !git_available() {
1744            return;
1745        }
1746        let dir = unique_temp_dir("git-interop");
1747        fs::create_dir_all(&dir).expect("test operation should succeed");
1748        let result = std::panic::catch_unwind(|| {
1749            let (git_dir, target) = init_repo_with_commit(&dir);
1750            let format = ObjectFormat::Sha1;
1751            let store = FileRefStore::new(&git_dir, format);
1752            let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
1753
1754            let mut git_add_cmd = Command::new("git");
1755            let git_add = git_env(git_add_cmd.current_dir(&dir).args([
1756                "notes",
1757                "add",
1758                "-m",
1759                "interop note",
1760                "HEAD",
1761            ]))
1762            .output()
1763            .expect("git notes add should run");
1764            assert!(
1765                git_add.status.success(),
1766                "git notes add failed: {}",
1767                String::from_utf8_lossy(&git_add.stderr)
1768            );
1769
1770            let sley_bytes = read_note_bytes(&git_dir, format, &store, &notes_ref, &target)
1771                .expect("test operation should succeed")
1772                .expect("note should exist");
1773
1774            let mut git_show_cmd = Command::new("git");
1775            let git_output = git_env(
1776                git_show_cmd
1777                    .current_dir(&dir)
1778                    .args(["notes", "show", "HEAD"]),
1779            )
1780            .output()
1781            .expect("test operation should succeed");
1782            assert!(
1783                git_output.status.success(),
1784                "git notes show failed: {}",
1785                String::from_utf8_lossy(&git_output.stderr)
1786            );
1787            assert_eq!(sley_bytes, git_output.stdout);
1788        });
1789        let _ = fs::remove_dir_all(&dir);
1790        result.expect("note_bytes_match_system_git assertions");
1791    }
1792
1793    fn heddle_notes_ref() -> NotesRef {
1794        NotesRef::expand("refs/notes/heddle")
1795    }
1796
1797    fn read_notes_head(store: &FileRefStore, notes_ref: &NotesRef) -> Option<ObjectId> {
1798        match store.read_ref(notes_ref.as_str()).expect("read ref") {
1799            Some(RefTarget::Direct(oid)) => Some(oid),
1800            _ => None,
1801        }
1802    }
1803
1804    fn install_fanout_note(
1805        git_dir: &Path,
1806        store: &FileRefStore,
1807        notes_ref: &NotesRef,
1808        annotated: &ObjectId,
1809        blob: ObjectId,
1810        identity: &NotesCommitIdentity,
1811    ) {
1812        let format = ObjectFormat::Sha1;
1813        let annotated_hex = annotated.to_hex();
1814        let mut db = FileObjectDatabase::from_git_dir(git_dir, format);
1815        let prefix = &annotated_hex[..2];
1816        let suffix = &annotated_hex[2..];
1817        let leaf = Tree {
1818            entries: vec![TreeEntry {
1819                mode: 0o100644,
1820                name: BString::from(suffix.as_bytes()),
1821                oid: blob,
1822            }],
1823        };
1824        let leaf_oid = db
1825            .write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
1826            .expect("leaf");
1827        let fanout = Tree {
1828            entries: vec![TreeEntry {
1829                mode: 0o040000,
1830                name: BString::from(prefix.as_bytes()),
1831                oid: leaf_oid,
1832            }],
1833        };
1834        let fanout_oid = db
1835            .write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
1836            .expect("fanout");
1837        let commit_oid = create_commit(
1838            &mut db,
1839            CommitCreate {
1840                tree: fanout_oid,
1841                parents: Vec::new(),
1842                author: identity.author.clone(),
1843                committer: identity.committer.clone(),
1844                message: b"fanout notes\n".to_vec(),
1845                encoding: None,
1846                signature: None,
1847            },
1848        )
1849        .expect("commit");
1850        let mut tx = store.transaction();
1851        tx.update(RefUpdate {
1852            name: notes_ref.as_str().to_string(),
1853            expected: notes_ref_expected(store, notes_ref).expect("ref expected"),
1854            new: RefTarget::Direct(commit_oid),
1855            reflog: None,
1856        });
1857        tx.commit().expect("update ref");
1858    }
1859
1860    #[test]
1861    fn upsert_note_for_unchanged_is_noop() {
1862        let dir = unique_temp_dir("upsert-unchanged");
1863        fs::create_dir_all(&dir).expect("create temp dir");
1864        let (git_dir, target) = init_repo_with_commit(&dir);
1865        let format = ObjectFormat::Sha1;
1866        let store = FileRefStore::new(&git_dir, format);
1867        let notes_ref = heddle_notes_ref();
1868        let identity = test_identity();
1869        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1870        let blob = write_blob(&mut db, br#"{"status":"served"}"#).expect("blob");
1871
1872        let first = upsert_note_for(
1873            &git_dir,
1874            format,
1875            &store,
1876            &notes_ref,
1877            &target,
1878            blob.clone(),
1879            "heddle: export",
1880            &identity,
1881            None,
1882        )
1883        .expect("first upsert");
1884        let first_head = read_notes_head(&store, &notes_ref).expect("head");
1885        assert!(matches!(first, UpsertNoteOutcome::Updated { .. }));
1886
1887        let second = upsert_note_for(
1888            &git_dir,
1889            format,
1890            &store,
1891            &notes_ref,
1892            &target,
1893            blob,
1894            "heddle: export",
1895            &identity,
1896            Some(RefTarget::Direct(first_head)),
1897        )
1898        .expect("second upsert");
1899        assert_eq!(second, UpsertNoteOutcome::Unchanged);
1900        assert_eq!(read_notes_head(&store, &notes_ref), Some(first_head));
1901        let _ = fs::remove_dir_all(&dir);
1902    }
1903
1904    #[test]
1905    fn upsert_note_for_updates_blob() {
1906        let dir = unique_temp_dir("upsert-update");
1907        fs::create_dir_all(&dir).expect("create temp dir");
1908        let (git_dir, target) = init_repo_with_commit(&dir);
1909        let format = ObjectFormat::Sha1;
1910        let store = FileRefStore::new(&git_dir, format);
1911        let notes_ref = heddle_notes_ref();
1912        let identity = test_identity();
1913        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1914        let blob_a = write_blob(&mut db, br#"{"v":1}"#).expect("blob a");
1915        let blob_b = write_blob(&mut db, br#"{"v":2}"#).expect("blob b");
1916
1917        let first = upsert_note_for(
1918            &git_dir,
1919            format,
1920            &store,
1921            &notes_ref,
1922            &target,
1923            blob_a,
1924            "heddle: export",
1925            &identity,
1926            None,
1927        )
1928        .expect("first upsert");
1929        let UpsertNoteOutcome::Updated {
1930            notes_commit: first_commit,
1931        } = first
1932        else {
1933            panic!("expected first upsert to update");
1934        };
1935
1936        let second = upsert_note_for(
1937            &git_dir,
1938            format,
1939            &store,
1940            &notes_ref,
1941            &target,
1942            blob_b,
1943            "heddle: export",
1944            &identity,
1945            Some(RefTarget::Direct(first_commit)),
1946        )
1947        .expect("second upsert");
1948        let UpsertNoteOutcome::Updated {
1949            notes_commit: second_commit,
1950        } = second
1951        else {
1952            panic!("expected second upsert to update");
1953        };
1954        assert_ne!(first_commit, second_commit);
1955        assert_eq!(
1956            read_note(&git_dir, format, &store, &notes_ref, &target).expect("read"),
1957            Some(blob_b)
1958        );
1959        let _ = fs::remove_dir_all(&dir);
1960    }
1961
1962    #[test]
1963    fn upsert_note_for_creates_ref() {
1964        let dir = unique_temp_dir("upsert-create");
1965        fs::create_dir_all(&dir).expect("create temp dir");
1966        let (git_dir, target) = init_repo_with_commit(&dir);
1967        let format = ObjectFormat::Sha1;
1968        let store = FileRefStore::new(&git_dir, format);
1969        let notes_ref = heddle_notes_ref();
1970        let identity = test_identity();
1971        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
1972        let blob = write_blob(&mut db, br#"{"status":"served"}"#).expect("blob");
1973
1974        assert_eq!(read_notes_head(&store, &notes_ref), None);
1975        let outcome = upsert_note_for(
1976            &git_dir,
1977            format,
1978            &store,
1979            &notes_ref,
1980            &target,
1981            blob,
1982            "heddle: export",
1983            &identity,
1984            None,
1985        )
1986        .expect("upsert");
1987        assert!(matches!(outcome, UpsertNoteOutcome::Updated { .. }));
1988        assert!(read_notes_head(&store, &notes_ref).is_some());
1989        let _ = fs::remove_dir_all(&dir);
1990    }
1991
1992    #[test]
1993    fn upsert_note_for_cas_mismatch_fails() {
1994        let dir = unique_temp_dir("upsert-cas");
1995        fs::create_dir_all(&dir).expect("create temp dir");
1996        let (git_dir, target) = init_repo_with_commit(&dir);
1997        let format = ObjectFormat::Sha1;
1998        let store = FileRefStore::new(&git_dir, format);
1999        let notes_ref = heddle_notes_ref();
2000        let identity = test_identity();
2001        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2002        let blob_a = write_blob(&mut db, br#"{"v":1}"#).expect("blob a");
2003        let blob_b = write_blob(&mut db, br#"{"v":2}"#).expect("blob b");
2004
2005        upsert_note_for(
2006            &git_dir,
2007            format,
2008            &store,
2009            &notes_ref,
2010            &target,
2011            blob_a,
2012            "heddle: export",
2013            &identity,
2014            None,
2015        )
2016        .expect("seed note");
2017        let head = read_notes_head(&store, &notes_ref).expect("head");
2018        let wrong =
2019            ObjectId::from_hex(format, "cccccccccccccccccccccccccccccccccccccccc").expect("oid");
2020
2021        let err = upsert_note_for(
2022            &git_dir,
2023            format,
2024            &store,
2025            &notes_ref,
2026            &target,
2027            blob_b,
2028            "heddle: export",
2029            &identity,
2030            Some(RefTarget::Direct(wrong)),
2031        )
2032        .expect_err("cas mismatch");
2033        assert!(matches!(err, GitError::Transaction(_)));
2034        assert_eq!(read_notes_head(&store, &notes_ref), Some(head));
2035        let _ = fs::remove_dir_all(&dir);
2036    }
2037
2038    #[test]
2039    fn remove_notes_for_partial_hit() {
2040        let dir = unique_temp_dir("remove-partial");
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 other =
2049            ObjectId::from_hex(format, "dddddddddddddddddddddddddddddddddddddddd").expect("oid");
2050        let blob_a = write_blob(&mut db, br#"{"a":1}"#).expect("blob a");
2051        let blob_b = write_blob(&mut db, br#"{"b":2}"#).expect("blob b");
2052
2053        write_notes(
2054            &git_dir,
2055            format,
2056            &store,
2057            &notes_ref,
2058            &[
2059                Note {
2060                    annotated: target,
2061                    blob: blob_a,
2062                },
2063                Note {
2064                    annotated: other,
2065                    blob: blob_b,
2066                },
2067            ],
2068            "seed",
2069            &identity,
2070            None,
2071        )
2072        .expect("seed notes");
2073        let head = read_notes_head(&store, &notes_ref).expect("head");
2074
2075        let outcome = remove_notes_for(
2076            &git_dir,
2077            format,
2078            &store,
2079            &notes_ref,
2080            &[target],
2081            "heddle: retract",
2082            &identity,
2083            Some(RefTarget::Direct(head)),
2084        )
2085        .expect("remove");
2086        assert!(matches!(outcome, RemoveNoteOutcome::Removed { .. }));
2087        assert_eq!(
2088            read_note(&git_dir, format, &store, &notes_ref, &target).expect("read"),
2089            None
2090        );
2091        assert_eq!(
2092            read_note(&git_dir, format, &store, &notes_ref, &other).expect("read"),
2093            Some(blob_b)
2094        );
2095        let _ = fs::remove_dir_all(&dir);
2096    }
2097
2098    #[test]
2099    fn remove_notes_for_noop_when_missing() {
2100        let dir = unique_temp_dir("remove-noop");
2101        fs::create_dir_all(&dir).expect("create temp dir");
2102        let (git_dir, target) = init_repo_with_commit(&dir);
2103        let format = ObjectFormat::Sha1;
2104        let store = FileRefStore::new(&git_dir, format);
2105        let notes_ref = heddle_notes_ref();
2106        let identity = test_identity();
2107        let missing =
2108            ObjectId::from_hex(format, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee").expect("oid");
2109
2110        let absent = remove_notes_for(
2111            &git_dir,
2112            format,
2113            &store,
2114            &notes_ref,
2115            &[target],
2116            "heddle: retract",
2117            &identity,
2118            None,
2119        )
2120        .expect("remove absent ref");
2121        assert_eq!(absent, RemoveNoteOutcome::Unchanged);
2122
2123        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2124        let blob = write_blob(&mut db, br#"{"x":1}"#).expect("blob");
2125        upsert_note_for(
2126            &git_dir,
2127            format,
2128            &store,
2129            &notes_ref,
2130            &target,
2131            blob,
2132            "heddle: export",
2133            &identity,
2134            None,
2135        )
2136        .expect("seed");
2137        let head = read_notes_head(&store, &notes_ref).expect("head");
2138
2139        let noop = remove_notes_for(
2140            &git_dir,
2141            format,
2142            &store,
2143            &notes_ref,
2144            &[missing],
2145            "heddle: retract",
2146            &identity,
2147            Some(RefTarget::Direct(head)),
2148        )
2149        .expect("remove missing oid");
2150        assert_eq!(noop, RemoveNoteOutcome::Unchanged);
2151        assert_eq!(read_notes_head(&store, &notes_ref), Some(head));
2152        let _ = fs::remove_dir_all(&dir);
2153    }
2154
2155    #[test]
2156    fn remove_notes_for_batch_single_commit() {
2157        let dir = unique_temp_dir("remove-batch");
2158        fs::create_dir_all(&dir).expect("create temp dir");
2159        let (git_dir, _) = init_repo_with_commit(&dir);
2160        let format = ObjectFormat::Sha1;
2161        let store = FileRefStore::new(&git_dir, format);
2162        let notes_ref = heddle_notes_ref();
2163        let identity = test_identity();
2164        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2165        let first =
2166            ObjectId::from_hex(format, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("oid");
2167        let second =
2168            ObjectId::from_hex(format, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").expect("oid");
2169        let third =
2170            ObjectId::from_hex(format, "cccccccccccccccccccccccccccccccccccccccc").expect("oid");
2171        let blob_a = write_blob(&mut db, b"a\n").expect("blob");
2172        let blob_b = write_blob(&mut db, b"b\n").expect("blob");
2173        let blob_c = write_blob(&mut db, b"c\n").expect("blob");
2174
2175        write_notes(
2176            &git_dir,
2177            format,
2178            &store,
2179            &notes_ref,
2180            &[
2181                Note {
2182                    annotated: first,
2183                    blob: blob_a,
2184                },
2185                Note {
2186                    annotated: second,
2187                    blob: blob_b,
2188                },
2189                Note {
2190                    annotated: third,
2191                    blob: blob_c,
2192                },
2193            ],
2194            "seed",
2195            &identity,
2196            None,
2197        )
2198        .expect("seed");
2199        let head = read_notes_head(&store, &notes_ref).expect("head");
2200
2201        let RemoveNoteOutcome::Removed { notes_commit } = remove_notes_for(
2202            &git_dir,
2203            format,
2204            &store,
2205            &notes_ref,
2206            &[first, second],
2207            "heddle: retract",
2208            &identity,
2209            Some(RefTarget::Direct(head)),
2210        )
2211        .expect("batch remove") else {
2212            panic!("expected removal");
2213        };
2214
2215        let db = FileObjectDatabase::from_git_dir(&git_dir, format);
2216        let commit = db.read_object(&notes_commit).expect("read commit");
2217        let commit = Commit::parse_ref(format, &commit.body).expect("parse");
2218        assert_eq!(commit.parents.len(), 1);
2219        assert_eq!(commit.parents[0], head);
2220        assert_eq!(
2221            list_notes(&git_dir, format, &store, &notes_ref)
2222                .expect("list")
2223                .len(),
2224            1
2225        );
2226        let _ = fs::remove_dir_all(&dir);
2227    }
2228
2229    #[test]
2230    fn incremental_ops_read_fanout_legacy() {
2231        let dir = unique_temp_dir("incremental-fanout");
2232        fs::create_dir_all(&dir).expect("create temp dir");
2233        let (git_dir, target) = init_repo_with_commit(&dir);
2234        let format = ObjectFormat::Sha1;
2235        let store = FileRefStore::new(&git_dir, format);
2236        let notes_ref = heddle_notes_ref();
2237        let identity = test_identity();
2238        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2239        let blob = write_blob(&mut db, br#"{"legacy":true}"#).expect("blob");
2240
2241        install_fanout_note(&git_dir, &store, &notes_ref, &target, blob, &identity);
2242        let head = read_notes_head(&store, &notes_ref).expect("head");
2243        let new_blob = write_blob(&mut db, br#"{"legacy":false}"#).expect("new blob");
2244
2245        upsert_note_for(
2246            &git_dir,
2247            format,
2248            &store,
2249            &notes_ref,
2250            &target,
2251            new_blob,
2252            "heddle: export",
2253            &identity,
2254            Some(RefTarget::Direct(head)),
2255        )
2256        .expect("upsert fanout");
2257
2258        assert_eq!(
2259            read_note_for(&git_dir, format, &store, &notes_ref, &target).expect("read"),
2260            Some(new_blob)
2261        );
2262        let _ = fs::remove_dir_all(&dir);
2263    }
2264
2265    #[test]
2266    fn incremental_ops_ff_chain() {
2267        let dir = unique_temp_dir("incremental-ff");
2268        fs::create_dir_all(&dir).expect("create temp dir");
2269        let (git_dir, target) = init_repo_with_commit(&dir);
2270        let format = ObjectFormat::Sha1;
2271        let store = FileRefStore::new(&git_dir, format);
2272        let notes_ref = heddle_notes_ref();
2273        let identity = test_identity();
2274        let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
2275        let other =
2276            ObjectId::from_hex(format, "ffffffffffffffffffffffffffffffffffffffff").expect("oid");
2277        let blob_a = write_blob(&mut db, br#"{"first":true}"#).expect("blob a");
2278        let blob_b = write_blob(&mut db, br#"{"second":true}"#).expect("blob b");
2279
2280        let UpsertNoteOutcome::Updated {
2281            notes_commit: first_commit,
2282        } = upsert_note_for(
2283            &git_dir,
2284            format,
2285            &store,
2286            &notes_ref,
2287            &target,
2288            blob_a,
2289            "heddle: export",
2290            &identity,
2291            None,
2292        )
2293        .expect("first upsert")
2294        else {
2295            panic!("expected update");
2296        };
2297
2298        let UpsertNoteOutcome::Updated {
2299            notes_commit: second_commit,
2300        } = upsert_note_for(
2301            &git_dir,
2302            format,
2303            &store,
2304            &notes_ref,
2305            &other,
2306            blob_b,
2307            "heddle: export",
2308            &identity,
2309            Some(RefTarget::Direct(first_commit)),
2310        )
2311        .expect("second upsert")
2312        else {
2313            panic!("expected update");
2314        };
2315
2316        let db = FileObjectDatabase::from_git_dir(&git_dir, format);
2317        let object = db.read_object(&second_commit).expect("read commit");
2318        let commit = Commit::parse_ref(format, &object.body).expect("parse");
2319        assert_eq!(commit.parents, vec![first_commit]);
2320        let _ = fs::remove_dir_all(&dir);
2321    }
2322}