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