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