Skip to main content

grit_lib/notes/
core.rs

1//! `git notes` tree manipulation — the fanout tree mapping `object -> note blob`.
2//!
3//! Notes are stored as blobs in a tree referenced by `refs/notes/commits` (or a
4//! custom namespace via `--ref`). Each leaf in the notes tree is named by the
5//! full hex SHA of the annotated object, optionally split into a fanout layout
6//! (`ab/cd/ef…`) once the note count grows large.
7//!
8//! This module owns the *pure tree operations* over the object database: reading
9//! the notes tree into a flat entry list, computing the fanout layout, writing a
10//! new notes tree + commit, combining note blobs (concatenate / cat_sort_uniq),
11//! and the notes-merge data model (pairing local/remote/base changes and applying
12//! a merge strategy). Notes refs are read and updated through the repository's
13//! [`RefStore`](crate::refs::store::RefStore), so every ref backend (loose/packed,
14//! reftable, or an injected store) sees the same notes.
15
16use std::collections::{BTreeMap, BTreeSet, HashMap};
17use std::fs;
18
19use time::{OffsetDateTime, UtcOffset};
20
21use crate::commit::assemble_identity;
22use crate::config::ConfigSet;
23use crate::error::{Error, Result};
24use crate::ident::parse_signature_times;
25use crate::ident_resolve::{IdentRole, IdentityEnv};
26use crate::merge_base::merge_bases_first_vs_rest;
27use crate::objects::{
28    parse_commit, parse_tree, serialize_commit, serialize_tree, tree_entry_cmp, CommitData,
29    ObjectId, ObjectKind, TreeEntry,
30};
31use crate::refs::should_autocreate_reflog_with_config;
32use crate::refs::store::{Expected, RawRef, ReflogUpdate};
33use crate::refs::store_write_value_on_store;
34use crate::repo::Repository;
35use crate::rev_parse::resolve_revision;
36use merge3::{Merge3, StandardMarkers};
37
38/// Per-worktree subdirectory holding the conflicted note blobs during a notes merge.
39const NOTES_MERGE_WORKTREE: &str = "NOTES_MERGE_WORKTREE";
40
41/// One leaf entry in a notes fanout tree (blob pointing at a note object).
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct NotesTreeEntry {
44    /// Git tree entry mode for the note blob (typically `100644`).
45    pub mode: u32,
46    /// Fanout path segments ending in the annotated object's hex id.
47    pub path: Vec<u8>,
48    /// Note blob object id stored at this path.
49    pub oid: ObjectId,
50}
51
52/// One annotated object's note change: `(object_id, old_blob, new_blob)`.
53type NoteBlobChange = (ObjectId, Option<ObjectId>, Option<ObjectId>);
54
55enum NotesTreeChild {
56    Blob {
57        /// Tree entry mode for the note blob.
58        mode: u32,
59        /// Note blob object id.
60        oid: ObjectId,
61    },
62    Tree(Vec<NotesTreeEntry>),
63}
64
65/// Extract the annotated object's hex id from a notes tree entry path, if well-formed.
66pub fn note_object_name(path: &[u8]) -> Option<String> {
67    let compact: Vec<u8> = path.iter().copied().filter(|byte| *byte != b'/').collect();
68    if !ObjectId::is_hex_len(compact.len()) || !compact.iter().all(u8::is_ascii_hexdigit) {
69        return None;
70    }
71    String::from_utf8(compact)
72        .ok()
73        .map(|name| name.to_ascii_lowercase())
74}
75
76fn collect_notes_tree_entries(
77    repo: &Repository,
78    tree_oid: &ObjectId,
79    prefix: &[u8],
80    out: &mut Vec<NotesTreeEntry>,
81    max_listable_notes: Option<usize>,
82    listable_seen: &mut usize,
83    truncated: &mut bool,
84) -> Result<()> {
85    if max_listable_notes.is_some_and(|max| *listable_seen >= max) {
86        *truncated = true;
87        return Ok(());
88    }
89    let tree_obj = repo.odb().read(tree_oid)?;
90    if tree_obj.kind != ObjectKind::Tree {
91        return Err(Error::NotesObjectKind {
92            oid: *tree_oid,
93            expected: ObjectKind::Tree,
94            found: tree_obj.kind,
95        });
96    }
97
98    for entry in parse_tree(&tree_obj.data)? {
99        if max_listable_notes.is_some_and(|max| *listable_seen >= max) {
100            *truncated = true;
101            return Ok(());
102        }
103        let mut path = prefix.to_vec();
104        if !path.is_empty() {
105            path.push(b'/');
106        }
107        path.extend_from_slice(&entry.name);
108
109        if entry.mode == 0o040000 {
110            collect_notes_tree_entries(
111                repo,
112                &entry.oid,
113                &path,
114                out,
115                max_listable_notes,
116                listable_seen,
117                truncated,
118            )?;
119            if *truncated {
120                return Ok(());
121            }
122        } else {
123            let counts = note_object_name(&path).is_some();
124            if max_listable_notes.is_none() || counts {
125                out.push(NotesTreeEntry {
126                    mode: entry.mode,
127                    path,
128                    oid: entry.oid,
129                });
130            }
131            if counts {
132                *listable_seen += 1;
133                if max_listable_notes.is_some_and(|max| *listable_seen >= max) {
134                    *truncated = true;
135                    return Ok(());
136                }
137            }
138        }
139    }
140
141    Ok(())
142}
143
144/// Current target of `notes_ref` in the repository's ref store, or `None` when the ref is absent.
145fn notes_ref_tip(repo: &Repository, notes_ref: &str) -> Result<Option<ObjectId>> {
146    match repo.refs().read_raw(notes_ref)? {
147        None => Ok(None),
148        Some(RawRef::Direct(oid)) => Ok(Some(oid)),
149        Some(RawRef::Symbolic(_)) => repo.refs().resolve(notes_ref).map(Some),
150    }
151}
152
153/// Read the notes tree entries from the notes ref.
154///
155/// `notes_ref` is looked up in the repository's ref store; when no such ref exists it is
156/// resolved as a revision instead (so a notes commit or tree id works too). The target may be a
157/// notes commit or a bare tree.
158///
159/// # Errors
160///
161/// - [`Error::NotesRefNotCommit`] when the target is neither a commit nor a tree.
162/// - [`Error::NotesObjectKind`] when a subtree entry is not a tree.
163/// - Ref store and object read failures.
164///
165/// Returns an empty vec when neither a ref nor a revision named `notes_ref` exists.
166pub fn read_notes_tree(repo: &Repository, notes_ref: &str) -> Result<Vec<NotesTreeEntry>> {
167    Ok(read_notes_snapshot(repo, notes_ref)?.entries)
168}
169
170/// Notes tree entries together with the notes ref tip they were read from.
171pub(crate) struct NotesSnapshot {
172    /// Tip of the notes ref when the entries were read; `None` when the ref did not exist.
173    /// Writes based on these entries must use it as both parent and compare-and-swap value.
174    pub(crate) tip: Option<ObjectId>,
175    /// Flat note entries of the tree at `tip`.
176    pub(crate) entries: Vec<NotesTreeEntry>,
177}
178
179/// Like [`read_notes_tree`], also returning the ref tip the entries came from.
180pub(crate) fn read_notes_snapshot(repo: &Repository, notes_ref: &str) -> Result<NotesSnapshot> {
181    let ref_tip = notes_ref_tip(repo, notes_ref)?;
182    let tip = match ref_tip {
183        Some(oid) => oid,
184        None => match resolve_revision(repo, notes_ref) {
185            Ok(oid) => oid,
186            Err(_) => {
187                return Ok(NotesSnapshot {
188                    tip: None,
189                    entries: Vec::new(),
190                })
191            }
192        },
193    };
194    let obj = repo.odb().read(&tip)?;
195    let tree_oid = match obj.kind {
196        ObjectKind::Commit => parse_commit(&obj.data)?.tree,
197        ObjectKind::Tree => tip,
198        found => {
199            return Err(Error::NotesRefNotCommit {
200                notes_ref: notes_ref.to_owned(),
201                found,
202            })
203        }
204    };
205    let mut entries = Vec::new();
206    let mut listable_seen = 0usize;
207    let mut truncated = false;
208    collect_notes_tree_entries(
209        repo,
210        &tree_oid,
211        b"",
212        &mut entries,
213        None,
214        &mut listable_seen,
215        &mut truncated,
216    )?;
217    debug_assert!(!truncated, "unlimited notes collect must not truncate");
218    Ok(NotesSnapshot {
219        tip: ref_tip,
220        entries,
221    })
222}
223
224/// Read note tree entries, stopping after `max_listable_notes` **listable** notes when set.
225///
226/// Non-object path segments (for example fanout `!` markers) do not count toward the cap.
227///
228/// Returns `(entries, truncated)` where `truncated` is true when the tree held more listable
229/// notes than returned.
230pub fn read_notes_tree_limited(
231    repo: &Repository,
232    notes_ref: &str,
233    max_entries: Option<usize>,
234) -> Result<(Vec<NotesTreeEntry>, bool)> {
235    let ref_tip = notes_ref_tip(repo, notes_ref)?;
236    let tip = match ref_tip {
237        Some(oid) => oid,
238        None => match resolve_revision(repo, notes_ref) {
239            Ok(oid) => oid,
240            Err(_) => return Ok((Vec::new(), false)),
241        },
242    };
243    let obj = repo.odb().read(&tip)?;
244    let tree_oid = match obj.kind {
245        ObjectKind::Commit => parse_commit(&obj.data)?.tree,
246        ObjectKind::Tree => tip,
247        found => {
248            return Err(Error::NotesRefNotCommit {
249                notes_ref: notes_ref.to_owned(),
250                found,
251            })
252        }
253    };
254    let mut entries = Vec::new();
255    let mut listable_seen = 0usize;
256    let mut truncated = false;
257    collect_notes_tree_entries(
258        repo,
259        &tree_oid,
260        b"",
261        &mut entries,
262        max_entries,
263        &mut listable_seen,
264        &mut truncated,
265    )?;
266    Ok((entries, truncated))
267}
268
269fn notes_fanout(entries: &[NotesTreeEntry]) -> usize {
270    let mut note_count = entries
271        .iter()
272        .filter(|entry| note_object_name(&entry.path).is_some())
273        .count();
274    let mut fanout = 0usize;
275    while note_count > 0xff {
276        note_count >>= 8;
277        fanout += 1;
278    }
279    fanout
280}
281
282fn path_with_fanout(hex: &str, fanout: usize) -> Vec<u8> {
283    let mut path = Vec::with_capacity(hex.len() + fanout);
284    let bytes = hex.as_bytes();
285    let split = fanout.min(bytes.len() / 2);
286    for idx in 0..split {
287        let start = idx * 2;
288        path.extend_from_slice(&bytes[start..start + 2]);
289        path.push(b'/');
290    }
291    path.extend_from_slice(&bytes[split * 2..]);
292    path
293}
294
295fn write_notes_subtree(repo: &Repository, entries: &[NotesTreeEntry]) -> Result<ObjectId> {
296    let mut children: BTreeMap<Vec<u8>, NotesTreeChild> = BTreeMap::new();
297
298    for entry in entries {
299        if let Some(slash_pos) = entry.path.iter().position(|byte| *byte == b'/') {
300            let child_name = entry.path[..slash_pos].to_vec();
301            let child_entry = NotesTreeEntry {
302                mode: entry.mode,
303                path: entry.path[slash_pos + 1..].to_vec(),
304                oid: entry.oid,
305            };
306            children
307                .entry(child_name)
308                .or_insert_with(|| NotesTreeChild::Tree(Vec::new()));
309            if let Some(NotesTreeChild::Tree(tree_entries)) =
310                children.get_mut(&entry.path[..slash_pos])
311            {
312                tree_entries.push(child_entry);
313            }
314        } else {
315            children.insert(
316                entry.path.clone(),
317                NotesTreeChild::Blob {
318                    mode: entry.mode,
319                    oid: entry.oid,
320                },
321            );
322        }
323    }
324
325    let mut tree_entries = Vec::with_capacity(children.len());
326    for (name, child) in children {
327        match child {
328            NotesTreeChild::Blob { mode, oid } => tree_entries.push(TreeEntry { mode, name, oid }),
329            NotesTreeChild::Tree(child_entries) => {
330                let oid = write_notes_subtree(repo, &child_entries)?;
331                tree_entries.push(TreeEntry {
332                    mode: 0o040000,
333                    name,
334                    oid,
335                });
336            }
337        }
338    }
339
340    tree_entries
341        .sort_by(|a, b| tree_entry_cmp(&a.name, a.mode == 0o040000, &b.name, b.mode == 0o040000));
342
343    let tree_data = serialize_tree(&tree_entries);
344    repo.odb().write(ObjectKind::Tree, &tree_data)
345}
346
347/// Write `entries` as a notes tree, re-laying out note paths with the fanout their count needs.
348fn write_notes_tree(repo: &Repository, entries: &[NotesTreeEntry]) -> Result<ObjectId> {
349    let fanout = notes_fanout(entries);
350    let rewritten_entries: Vec<_> = entries
351        .iter()
352        .map(|entry| NotesTreeEntry {
353            mode: entry.mode,
354            path: note_object_name(&entry.path)
355                .map(|name| path_with_fanout(&name, fanout))
356                .unwrap_or_else(|| entry.path.clone()),
357            oid: entry.oid,
358        })
359        .collect();
360    write_notes_subtree(repo, &rewritten_entries)
361}
362
363/// Identity lines and message for one notes commit.
364pub(crate) struct NotesCommit<'a> {
365    pub(crate) message: &'a str,
366    /// Full author line (`Name <email> unix tz`).
367    pub(crate) author: &'a str,
368    /// Full committer line (`Name <email> unix tz`).
369    pub(crate) committer: &'a str,
370}
371
372/// Write the notes tree for `entries` and a commit on top of `parents`; returns the commit id.
373pub(crate) fn preview_notes_commit(
374    repo: &Repository,
375    parent: Option<ObjectId>,
376    entries: &[NotesTreeEntry],
377    commit: &NotesCommit<'_>,
378) -> Result<ObjectId> {
379    write_notes_commit_object(repo, entries, commit, parent.into_iter().collect())
380}
381
382fn write_notes_commit_object(
383    repo: &Repository,
384    entries: &[NotesTreeEntry],
385    commit: &NotesCommit<'_>,
386    parents: Vec<ObjectId>,
387) -> Result<ObjectId> {
388    let tree = write_notes_tree(repo, entries)?;
389    let message = if commit.message.ends_with('\n') {
390        commit.message.to_owned()
391    } else {
392        format!("{}\n", commit.message)
393    };
394    let data = CommitData {
395        tree,
396        parents,
397        author: commit.author.to_owned(),
398        committer: commit.committer.to_owned(),
399        author_raw: Vec::new(),
400        committer_raw: Vec::new(),
401        encoding: None,
402        message,
403        raw_message: None,
404        extra_headers: Vec::new(),
405    };
406    repo.odb()
407        .write(ObjectKind::Commit, &serialize_commit(&data))
408}
409
410/// Timestamp of a full identity line, for the reflog entry written next to it.
411fn signature_time(ident: &str) -> OffsetDateTime {
412    parse_signature_times(ident)
413        .and_then(|times| {
414            let offset =
415                UtcOffset::from_whole_seconds(i32::try_from(times.tz_offset_secs).ok()?).ok()?;
416            let at = OffsetDateTime::from_unix_timestamp(times.unix_seconds).ok()?;
417            Some(at.to_offset(offset))
418        })
419        .unwrap_or(OffsetDateTime::UNIX_EPOCH)
420}
421
422/// Move `notes_ref` from `old` (`None`: the ref must not exist yet) to `new`, logging the update
423/// in the same ref transaction when the ref has or may auto-create a reflog.
424fn update_notes_ref(
425    repo: &Repository,
426    notes_ref: &str,
427    old: Option<ObjectId>,
428    new: ObjectId,
429    commit: &NotesCommit<'_>,
430) -> Result<()> {
431    let config = repo.config()?;
432    let log = should_autocreate_reflog_with_config(config.as_ref(), repo.git_dir(), notes_ref)
433        || repo.refs().reflog_exists(notes_ref)?;
434    let reflog = log.then(|| ReflogUpdate {
435        identity: commit.committer.to_owned(),
436        message: format!("notes: {}", commit.message.trim_end_matches('\n')),
437        time: signature_time(commit.committer),
438        old_oid: None,
439        new_oid: None,
440    });
441    store_write_value_on_store(
442        repo.refs(),
443        repo.git_dir(),
444        notes_ref,
445        Some(RawRef::Direct(new)),
446        old.map_or(Expected::Missing, Expected::Oid),
447        reflog,
448    )
449}
450
451/// Commit `entries` on top of `parent` and move `notes_ref` from `parent` to the new commit.
452///
453/// `parent` must be the tip the entries were read from, so a concurrent update in between
454/// fails the compare-and-swap instead of being dropped from the new tree.
455fn commit_notes_tree(
456    repo: &Repository,
457    notes_ref: &str,
458    parent: Option<ObjectId>,
459    entries: &[NotesTreeEntry],
460    commit: &NotesCommit<'_>,
461) -> Result<ObjectId> {
462    let oid = write_notes_commit_object(repo, entries, commit, parent.into_iter().collect())?;
463    update_notes_ref(repo, notes_ref, parent, oid, commit)?;
464    Ok(oid)
465}
466
467/// Write a new notes tree and commit, updating the notes ref.
468///
469/// Author and committer come from `GIT_{AUTHOR,COMMITTER}_{NAME,EMAIL,DATE}` and `user.*`
470/// config, timestamped with the repository's wall clock. Use
471/// [`RepositoryNotes`](crate::repo_handles::RepositoryNotes) to pass explicit identities.
472///
473/// `entries` replace the whole notes tree. The commit's parent and the compare-and-swap value
474/// are the tip read here, so an update made after the caller read `entries` but before this call
475/// is overwritten; [`RepositoryNotes`](crate::repo_handles::RepositoryNotes) reads and writes
476/// against one tip and rejects such races.
477///
478/// # Errors
479///
480/// - Ref store errors, including a compare-and-swap failure when `notes_ref` moves while the
481///   commit is written.
482/// - Object write and config load failures.
483pub fn write_notes_commit(
484    repo: &Repository,
485    notes_ref: &str,
486    entries: &[NotesTreeEntry],
487    message: &str,
488) -> Result<()> {
489    let (author, committer) = wall_clock_notes_idents(repo)?;
490    let parent = notes_ref_tip(repo, notes_ref)?;
491    commit_notes_tree(
492        repo,
493        notes_ref,
494        parent,
495        entries,
496        &NotesCommit {
497            message,
498            author: &author,
499            committer: &committer,
500        },
501    )?;
502    Ok(())
503}
504
505/// Write a notes tree commit using explicit author and committer identity lines.
506///
507/// Identity strings must already include timestamp and timezone (`Name <email> unix tz`).
508/// `parent` is the [`NotesSnapshot::tip`] the entries were read from.
509pub(crate) fn write_notes_commit_idents(
510    repo: &Repository,
511    notes_ref: &str,
512    parent: Option<ObjectId>,
513    entries: &[NotesTreeEntry],
514    message: &str,
515    author: &str,
516    committer: &str,
517) -> Result<()> {
518    commit_notes_tree(
519        repo,
520        notes_ref,
521        parent,
522        entries,
523        &NotesCommit {
524            message,
525            author,
526            committer,
527        },
528    )?;
529    Ok(())
530}
531
532/// Remove a note for `object_hex` when present. Returns whether an entry was removed.
533#[must_use]
534pub fn remove_note_entry(entries: &mut Vec<NotesTreeEntry>, object_hex: &str) -> bool {
535    let before = entries.len();
536    entries.retain(|e| note_object_name(&e.path).as_deref() != Some(object_hex));
537    entries.len() < before
538}
539
540/// Author and committer lines for notes commits written without explicit identities.
541fn wall_clock_notes_idents(repo: &Repository) -> Result<(String, String)> {
542    let config = repo.config()?;
543    let now = repo.identity_now();
544    Ok((
545        notes_ident(repo.environment(), config.as_ref(), IdentRole::Author, now),
546        notes_ident(
547            repo.environment(),
548            config.as_ref(),
549            IdentRole::Committer,
550            now,
551        ),
552    ))
553}
554
555/// Identity line for a notes commit from `GIT_<ROLE>_{NAME,EMAIL,DATE}` (the committer also
556/// falls back to the author variables), then `user.name` / `user.email`.
557fn notes_ident<E: IdentityEnv>(
558    env: &E,
559    config: &ConfigSet,
560    role: IdentRole,
561    now: OffsetDateTime,
562) -> String {
563    let (name_key, email_key, date_key) = match role {
564        IdentRole::Author => ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_AUTHOR_DATE"),
565        IdentRole::Committer => (
566            "GIT_COMMITTER_NAME",
567            "GIT_COMMITTER_EMAIL",
568            "GIT_COMMITTER_DATE",
569        ),
570    };
571    let non_blank = |key: &str| env.var(key).filter(|v| !v.trim().is_empty());
572    let author_fallback = |key: &str| match role {
573        IdentRole::Committer => non_blank(key),
574        IdentRole::Author => None,
575    };
576
577    let name = non_blank(name_key)
578        .or_else(|| author_fallback("GIT_AUTHOR_NAME"))
579        .or_else(|| config.get("user.name"))
580        .unwrap_or_else(|| "Unknown".to_owned());
581    let email = non_blank(email_key)
582        .or_else(|| author_fallback("GIT_AUTHOR_EMAIL"))
583        .or_else(|| config.get("user.email"))
584        .unwrap_or_default();
585    assemble_identity(&name, &email, non_blank(date_key).as_deref(), now)
586}
587
588/// How to combine conflicting note blobs during a notes merge.
589#[derive(Clone, Copy, Debug, PartialEq, Eq)]
590pub enum NotesMergeStrategy {
591    /// Leave conflicts for the user to resolve in the worktree.
592    Manual,
593    /// Keep the local note blob on conflict.
594    Ours,
595    /// Keep the remote note blob on conflict.
596    Theirs,
597    /// Concatenate both note blobs on conflict.
598    Union,
599    /// Concatenate, sort lines, and deduplicate on conflict.
600    CatSortUniq,
601}
602
603#[derive(Clone, Debug)]
604enum LocalNoteState {
605    Unset,
606    Deleted,
607    Present(ObjectId),
608}
609
610#[derive(Clone, Debug)]
611struct NotesMergePair {
612    obj: ObjectId,
613    base_blob: Option<ObjectId>,
614    remote_blob: Option<ObjectId>,
615    local: LocalNoteState,
616}
617
618impl NotesMergePair {
619    /// The local side's note relative to the merge base: `None` when local left the note as it
620    /// was at the base, `Some(None)` when local deleted it, `Some(Some(blob))` when local set it.
621    fn local_change(&self) -> Option<Option<ObjectId>> {
622        match self.local {
623            LocalNoteState::Unset => None,
624            LocalNoteState::Present(blob) if Some(blob) == self.base_blob => None,
625            LocalNoteState::Present(blob) => Some(Some(blob)),
626            LocalNoteState::Deleted => Some(None),
627        }
628    }
629}
630
631/// Full ref name for a Git notes namespace (`refs/notes/…`).
632#[derive(Debug, Clone, PartialEq, Eq, Hash)]
633pub struct NotesRef(String);
634
635impl NotesRef {
636    /// Default notes ref (`refs/notes/commits`).
637    #[must_use]
638    pub fn commits() -> Self {
639        Self("refs/notes/commits".to_owned())
640    }
641
642    /// Parse a full ref name that must live under `refs/notes/`.
643    ///
644    /// Used for `GIT_NOTES_REF`, `core.notesRef`, and other configured refs (not
645    /// `--ref`, which uses [`Self::parse`]).
646    ///
647    /// # Errors
648    ///
649    /// Returns [`Error::InvalidNotesRef`](crate::error::Error::InvalidNotesRef) when
650    /// `full` is not prefixed with `refs/notes/` or is not a valid ref name (env/config
651    /// values are not trimmed or otherwise normalized).
652    pub fn from_full(full: &str) -> Result<Self> {
653        if !full.starts_with("refs/notes/") {
654            return Err(Error::InvalidNotesRef(full.to_owned()));
655        }
656        use crate::check_ref_format::{check_refname_format, RefNameOptions};
657        if check_refname_format(full, &RefNameOptions::default()).is_err() {
658            return Err(Error::InvalidNotesRef(full.to_owned()));
659        }
660        Ok(Self(full.to_owned()))
661    }
662
663    /// Parse a short or full notes ref the same way Git's `--ref` flag does: `name` and
664    /// `notes/name` both become `refs/notes/name`; a name already under `refs/notes/` is kept.
665    #[must_use]
666    pub fn parse(short_or_full: &str) -> Self {
667        Self(expand_notes_ref(short_or_full))
668    }
669
670    /// Borrow the canonical ref string.
671    #[must_use]
672    pub fn as_str(&self) -> &str {
673        &self.0
674    }
675}
676
677impl std::fmt::Display for NotesRef {
678    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
679        f.write_str(&self.0)
680    }
681}
682
683/// Resolve the default notes ref from `GIT_NOTES_REF`, then `core.notesRef`, then
684/// [`NotesRef::commits`].
685///
686/// Config and environment values must be full refs under `refs/notes/` (same rules
687/// as Git's `init_notes_ref`).
688///
689/// # Errors
690///
691/// Returns [`Error::InvalidNotesRef`](crate::error::Error::InvalidNotesRef) when a
692/// configured value is empty or outside `refs/notes/`.
693pub fn resolve_configured_notes_ref<E: IdentityEnv>(
694    env: &E,
695    config: &ConfigSet,
696) -> Result<NotesRef> {
697    if let Some(value) = env.var("GIT_NOTES_REF") {
698        return NotesRef::from_full(&value);
699    }
700    if let Some(value) = config.get("core.notesRef") {
701        return NotesRef::from_full(&value);
702    }
703    Ok(NotesRef::commits())
704}
705
706/// Expansion behind [`NotesRef::parse`]; configured refs are used verbatim instead.
707fn expand_notes_ref(short_or_full: &str) -> String {
708    if short_or_full.starts_with("refs/notes/") {
709        short_or_full.to_owned()
710    } else if short_or_full.starts_with("notes/") {
711        format!("refs/{short_or_full}")
712    } else {
713        format!("refs/notes/{short_or_full}")
714    }
715}
716
717/// Path to the per-merge worktree directory under the repository git dir.
718fn notes_merge_worktree_path(repo: &Repository) -> std::path::PathBuf {
719    repo.git_dir().join(NOTES_MERGE_WORKTREE)
720}
721
722/// True when `NOTES_MERGE_WORKTREE` exists and holds at least one entry.
723fn notes_merge_worktree_nonempty(worktree: &std::path::Path) -> bool {
724    if !worktree.is_dir() {
725        return false;
726    }
727    let Ok(entries) = fs::read_dir(worktree) else {
728        return false;
729    };
730    entries.flatten().next().is_some()
731}
732
733/// Map annotated object → note blob OID for one notes tree (any fanout layout).
734fn notes_tree_blob_by_object(
735    repo: &Repository,
736    tree_oid: &ObjectId,
737) -> Result<HashMap<ObjectId, ObjectId>> {
738    let mut flat = Vec::new();
739    let mut listable_seen = 0usize;
740    let mut truncated = false;
741    collect_notes_tree_entries(
742        repo,
743        tree_oid,
744        b"",
745        &mut flat,
746        None,
747        &mut listable_seen,
748        &mut truncated,
749    )?;
750    debug_assert!(!truncated);
751    let mut map = HashMap::new();
752    for entry in flat {
753        let Some(hex) = note_object_name(&entry.path) else {
754            continue;
755        };
756        map.insert(ObjectId::from_hex(&hex)?, entry.oid);
757    }
758    Ok(map)
759}
760
761fn diff_note_blob_changes(
762    repo: &Repository,
763    old_tree: Option<&ObjectId>,
764    new_tree: Option<&ObjectId>,
765) -> Result<Vec<NoteBlobChange>> {
766    let old_map = match old_tree {
767        Some(t) => notes_tree_blob_by_object(repo, t)?,
768        None => HashMap::new(),
769    };
770    let new_map = match new_tree {
771        Some(t) => notes_tree_blob_by_object(repo, t)?,
772        None => HashMap::new(),
773    };
774    let keys: BTreeSet<ObjectId> = old_map.keys().chain(new_map.keys()).copied().collect();
775    let mut out = Vec::new();
776    for obj in keys {
777        let o_old = old_map.get(&obj).copied();
778        let o_new = new_map.get(&obj).copied();
779        match (o_old, o_new) {
780            (None, Some(new_b)) => out.push((obj, None, Some(new_b))),
781            (Some(old_b), None) => out.push((obj, Some(old_b), None)),
782            (Some(old_b), Some(new_b)) if old_b != new_b => {
783                out.push((obj, Some(old_b), Some(new_b)));
784            }
785            _ => {}
786        }
787    }
788    Ok(out)
789}
790
791fn build_merge_pairs(
792    base_tree: Option<ObjectId>,
793    local_tree: ObjectId,
794    remote_tree: ObjectId,
795    repo: &Repository,
796) -> Result<Vec<NotesMergePair>> {
797    let remote_raw = diff_note_blob_changes(repo, base_tree.as_ref(), Some(&remote_tree))?;
798    let local_raw = diff_note_blob_changes(repo, base_tree.as_ref(), Some(&local_tree))?;
799    let mut map: HashMap<ObjectId, NotesMergePair> = HashMap::new();
800    for (obj, old_b, new_b) in remote_raw {
801        map.insert(
802            obj,
803            NotesMergePair {
804                obj,
805                base_blob: old_b,
806                remote_blob: new_b,
807                local: LocalNoteState::Unset,
808            },
809        );
810    }
811
812    for (obj, old_b, new_b) in local_raw {
813        let local_state = match new_b {
814            Some(new_oid) => LocalNoteState::Present(new_oid),
815            None => LocalNoteState::Deleted,
816        };
817        if let Some(p) = map.get_mut(&obj) {
818            p.local = local_state;
819        } else {
820            map.insert(
821                obj,
822                NotesMergePair {
823                    obj,
824                    base_blob: old_b,
825                    remote_blob: old_b,
826                    local: local_state,
827                },
828            );
829        }
830    }
831
832    let mut v: Vec<_> = map.into_values().collect();
833    v.sort_by_key(|a| a.obj);
834    Ok(v)
835}
836
837/// Bytes of the note blob `oid`.
838///
839/// # Errors
840///
841/// [`Error::NotesObjectKind`] when `oid` is not a blob, or object read failures.
842pub(crate) fn read_blob_bytes(repo: &Repository, oid: &ObjectId) -> Result<Vec<u8>> {
843    let obj = repo.odb().read(oid)?;
844    if obj.kind != ObjectKind::Blob {
845        return Err(Error::NotesObjectKind {
846            oid: *oid,
847            expected: ObjectKind::Blob,
848            found: obj.kind,
849        });
850    }
851    Ok(obj.data)
852}
853
854/// Join two note blobs with a blank line between them (Git's `concatenate` notes combiner).
855fn combine_notes_concatenate(
856    repo: &Repository,
857    cur: Option<&ObjectId>,
858    new_oid: Option<&ObjectId>,
859) -> Result<ObjectId> {
860    let new_data = match new_oid {
861        Some(n) => {
862            let obj = repo.odb().read(n)?;
863            if obj.kind != ObjectKind::Blob || obj.data.is_empty() {
864                Vec::new()
865            } else {
866                obj.data
867            }
868        }
869        None => Vec::new(),
870    };
871    if new_data.is_empty() {
872        let Some(c) = cur else {
873            return Err(Error::Message(
874                "combine_notes_concatenate: empty new and no current".into(),
875            ));
876        };
877        return Ok(*c);
878    }
879
880    let cur_data = match cur {
881        Some(c) => {
882            let obj = repo.odb().read(c)?;
883            if obj.kind != ObjectKind::Blob || obj.data.is_empty() {
884                Vec::new()
885            } else {
886                obj.data
887            }
888        }
889        None => Vec::new(),
890    };
891
892    if cur_data.is_empty() {
893        return repo.odb().write(ObjectKind::Blob, &new_data);
894    }
895
896    let mut cur_len = cur_data.len();
897    if cur_len > 0 && cur_data[cur_len - 1] == b'\n' {
898        cur_len -= 1;
899    }
900    let mut buf = Vec::with_capacity(cur_len + 2 + new_data.len());
901    buf.extend_from_slice(&cur_data[..cur_len]);
902    buf.push(b'\n');
903    buf.push(b'\n');
904    buf.extend_from_slice(&new_data);
905    repo.odb().write(ObjectKind::Blob, &buf)
906}
907
908fn note_blob_lines(data: &[u8]) -> Vec<String> {
909    if data.is_empty() {
910        return Vec::new();
911    }
912    let s = String::from_utf8_lossy(data);
913    s.split('\n').map(|l| l.to_owned()).collect()
914}
915
916/// All lines from both blobs, de-duplicated and sorted (Git's `cat_sort_uniq` notes combiner).
917fn combine_notes_cat_sort_uniq(
918    repo: &Repository,
919    cur: Option<&ObjectId>,
920    new_oid: Option<&ObjectId>,
921) -> Result<ObjectId> {
922    let mut lines: Vec<String> = Vec::new();
923    for oid in [cur, new_oid].into_iter().flatten() {
924        let obj = repo.odb().read(oid)?;
925        if obj.kind == ObjectKind::Blob && !obj.data.is_empty() {
926            lines.extend(note_blob_lines(&obj.data));
927        }
928    }
929    lines.retain(|l| !l.is_empty());
930    lines.sort();
931    lines.dedup();
932    let mut buf = String::new();
933    for l in &lines {
934        buf.push_str(l);
935        buf.push('\n');
936    }
937    repo.odb().write(ObjectKind::Blob, buf.as_bytes())
938}
939
940fn blob_to_lines(data: &[u8]) -> Vec<String> {
941    if data.is_empty() {
942        return vec![String::new()];
943    }
944    let s = String::from_utf8_lossy(data).into_owned();
945    s.split_inclusive('\n').map(|l| l.to_owned()).collect()
946}
947
948fn merge_note_blobs_conflict_markers(
949    repo: &Repository,
950    base: Option<&ObjectId>,
951    local: &ObjectId,
952    remote: &ObjectId,
953    local_ref: &str,
954    remote_ref: &str,
955) -> Result<Vec<u8>> {
956    let base_lines: Vec<String> = match base {
957        Some(b) => blob_to_lines(&read_blob_bytes(repo, b)?),
958        None => vec![String::new()],
959    };
960    let local_lines = blob_to_lines(&read_blob_bytes(repo, local)?);
961    let remote_lines = blob_to_lines(&read_blob_bytes(repo, remote)?);
962
963    let base_refs: Vec<&str> = base_lines.iter().map(|s| s.as_str()).collect();
964    let local_refs: Vec<&str> = local_lines.iter().map(|s| s.as_str()).collect();
965    let remote_refs: Vec<&str> = remote_lines.iter().map(|s| s.as_str()).collect();
966    let m3 = Merge3::new(&base_refs, &local_refs, &remote_refs);
967    let markers = StandardMarkers::new(Some(local_ref), Some(remote_ref));
968    let merged: String = m3
969        .merge_lines(false, &markers)
970        .into_iter()
971        .map(|cow| cow.into_owned())
972        .collect();
973    Ok(merged.into_bytes())
974}
975
976/// Note blobs on each side of one conflicting note in a notes merge (`None`: no note).
977struct NoteConflict {
978    base: Option<ObjectId>,
979    local: Option<ObjectId>,
980    remote: Option<ObjectId>,
981}
982
983fn remove_entry(entries: &mut Vec<NotesTreeEntry>, obj_hex: &str) {
984    entries.retain(|e| note_object_name(&e.path).as_deref() != Some(obj_hex));
985}
986
987/// Resolve one note changed on both sides; returns `true` when it is left as a conflict file.
988#[expect(clippy::too_many_arguments)]
989fn merge_one_note_change(
990    repo: &Repository,
991    obj: &ObjectId,
992    sides: &NoteConflict,
993    strategy: NotesMergeStrategy,
994    local_ref: &str,
995    remote_ref: &str,
996    worktree: &std::path::Path,
997    commit_msg: &mut String,
998    entries: &mut Vec<NotesTreeEntry>,
999    has_worktree: &mut bool,
1000) -> Result<bool> {
1001    let obj_hex = obj.to_hex();
1002    let combine = match strategy {
1003        NotesMergeStrategy::Ours => return Ok(false),
1004        NotesMergeStrategy::Theirs => {
1005            match sides.remote {
1006                Some(r) => upsert_note_entry(entries, &obj_hex, r),
1007                None => remove_entry(entries, &obj_hex),
1008            }
1009            return Ok(false);
1010        }
1011        NotesMergeStrategy::Union => combine_notes_concatenate,
1012        NotesMergeStrategy::CatSortUniq => combine_notes_cat_sort_uniq,
1013        NotesMergeStrategy::Manual => {
1014            let data = match (&sides.local, &sides.remote) {
1015                (None, None) => {
1016                    remove_entry(entries, &obj_hex);
1017                    return Ok(false);
1018                }
1019                (None, Some(r)) => read_blob_bytes(repo, r)?,
1020                (Some(l), None) => read_blob_bytes(repo, l)?,
1021                (Some(l), Some(r)) => merge_note_blobs_conflict_markers(
1022                    repo,
1023                    sides.base.as_ref(),
1024                    l,
1025                    r,
1026                    local_ref,
1027                    remote_ref,
1028                )?,
1029            };
1030            if !*has_worktree {
1031                if notes_merge_worktree_nonempty(worktree) {
1032                    return Err(Error::NotesMergeInProgress {
1033                        path: worktree.to_path_buf(),
1034                    });
1035                }
1036                fs::create_dir_all(worktree)?;
1037                *has_worktree = true;
1038            }
1039            if !commit_msg.contains("Conflicts:") {
1040                commit_msg.push_str("\n\nConflicts:\n");
1041            }
1042            commit_msg.push_str(&format!("\t{obj_hex}\n"));
1043            fs::write(worktree.join(&obj_hex), data)?;
1044            remove_entry(entries, &obj_hex);
1045            return Ok(true);
1046        }
1047    };
1048    match (&sides.local, &sides.remote) {
1049        (None, None) => remove_entry(entries, &obj_hex),
1050        (Some(_), None) => {}
1051        (local, Some(r)) => {
1052            let out = combine(repo, local.as_ref(), Some(r))?;
1053            upsert_note_entry(entries, &obj_hex, out);
1054        }
1055    }
1056    Ok(false)
1057}
1058
1059/// Replace or insert the note entry for `hex` in a flat notes entry list.
1060pub fn upsert_note_entry(entries: &mut Vec<NotesTreeEntry>, hex: &str, blob: ObjectId) {
1061    remove_entry(entries, hex);
1062    entries.push(NotesTreeEntry {
1063        mode: 0o100644,
1064        path: hex.as_bytes().to_vec(),
1065        oid: blob,
1066    });
1067}
1068
1069fn remote_unchanged(base: Option<ObjectId>, remote: Option<ObjectId>) -> bool {
1070    match (base, remote) {
1071        (Some(b), Some(r)) => b == r,
1072        (None, None) => true,
1073        _ => false,
1074    }
1075}
1076
1077fn same_change_local_remote(p: &NotesMergePair) -> bool {
1078    match (&p.local, p.remote_blob) {
1079        (LocalNoteState::Present(l), Some(r)) => l == &r,
1080        (LocalNoteState::Deleted, None) => true,
1081        (LocalNoteState::Unset, Some(r)) => Some(r) == p.base_blob,
1082        (LocalNoteState::Unset, None) => p.base_blob.is_none(),
1083        _ => false,
1084    }
1085}
1086
1087fn adopt_remote_note(entries: &mut Vec<NotesTreeEntry>, obj_hex: &str, remote: Option<ObjectId>) {
1088    match remote {
1089        Some(oid) => upsert_note_entry(entries, obj_hex, oid),
1090        None => remove_entry(entries, obj_hex),
1091    }
1092}
1093
1094#[expect(clippy::too_many_arguments)]
1095fn merge_changes_into_entries(
1096    repo: &Repository,
1097    pairs: &[NotesMergePair],
1098    strategy: NotesMergeStrategy,
1099    local_ref: &str,
1100    remote_ref: &str,
1101    worktree: &std::path::Path,
1102    commit_msg: &mut String,
1103    entries: &mut Vec<NotesTreeEntry>,
1104) -> Result<usize> {
1105    let mut conflicts = 0usize;
1106    let mut has_worktree = false;
1107    for p in pairs {
1108        if remote_unchanged(p.base_blob, p.remote_blob) {
1109            continue;
1110        }
1111        if same_change_local_remote(p) {
1112            continue;
1113        }
1114        let Some(local) = p.local_change() else {
1115            adopt_remote_note(entries, &p.obj.to_hex(), p.remote_blob);
1116            continue;
1117        };
1118        let sides = NoteConflict {
1119            base: p.base_blob,
1120            local,
1121            remote: p.remote_blob,
1122        };
1123        if merge_one_note_change(
1124            repo,
1125            &p.obj,
1126            &sides,
1127            strategy,
1128            local_ref,
1129            remote_ref,
1130            worktree,
1131            commit_msg,
1132            entries,
1133            &mut has_worktree,
1134        )? {
1135            conflicts += 1;
1136        }
1137    }
1138    Ok(conflicts)
1139}
1140
1141fn resolve_commit_tree(repo: &Repository, commit_oid: &ObjectId) -> Result<ObjectId> {
1142    let obj = repo.odb().read(commit_oid)?;
1143    if obj.kind != ObjectKind::Commit {
1144        return Err(Error::NotesObjectKind {
1145            oid: *commit_oid,
1146            expected: ObjectKind::Commit,
1147            found: obj.kind,
1148        });
1149    }
1150    Ok(parse_commit(&obj.data)?.tree)
1151}
1152
1153fn resolve_notes_commit_optional(repo: &Repository, notes_ref: &str) -> Result<Option<ObjectId>> {
1154    let Some(oid) = notes_ref_tip(repo, notes_ref)? else {
1155        return Ok(None);
1156    };
1157    let kind = repo.odb().read(&oid)?.kind;
1158    if kind != ObjectKind::Commit {
1159        return Err(Error::NotesRefNotCommit {
1160            notes_ref: notes_ref.to_owned(),
1161            found: kind,
1162        });
1163    }
1164    Ok(Some(oid))
1165}
1166
1167/// Whether a notes merge commit moves the notes ref.
1168#[derive(Clone, Copy)]
1169enum NotesRefUpdate {
1170    /// Store the commit in the ODB only (manual merge conflicts keep the live ref at the local tip).
1171    Leave,
1172    /// Move the ref, which must still point at this tip (`None`: must not exist).
1173    From(Option<ObjectId>),
1174}
1175
1176/// Write a notes merge commit with `parents`, then apply `update` to `notes_ref`.
1177fn write_notes_commit_with_parents(
1178    repo: &Repository,
1179    notes_ref: &str,
1180    entries: &[NotesTreeEntry],
1181    message: &str,
1182    parents: &[ObjectId],
1183    update: NotesRefUpdate,
1184) -> Result<ObjectId> {
1185    let (author, committer) = wall_clock_notes_idents(repo)?;
1186    let commit = NotesCommit {
1187        message,
1188        author: &author,
1189        committer: &committer,
1190    };
1191    let oid = write_notes_commit_object(repo, entries, &commit, parents.to_vec())?;
1192    if let NotesRefUpdate::From(old) = update {
1193        update_notes_ref(repo, notes_ref, old, oid, &commit)?;
1194    }
1195    Ok(oid)
1196}
1197
1198/// Merge two notes refs and return the resulting notes commit, or a conflict commit oid.
1199///
1200/// # Errors
1201///
1202/// - [`Error::NotesMergeBothEmpty`] when neither ref has a notes commit.
1203/// - [`Error::NotesRefNotCommit`] when either ref points at something other than a commit.
1204/// - [`Error::NotesMergeInProgress`] when [`NotesMergeStrategy::Manual`] finds conflict files
1205///   left by an earlier merge.
1206/// - Ref store, object, and merge-base failures, or a failure while combining blobs.
1207pub fn notes_merge_inner(
1208    repo: &Repository,
1209    local_ref: &str,
1210    remote_ref: &str,
1211    strategy: NotesMergeStrategy,
1212) -> Result<std::result::Result<ObjectId, ObjectId>> {
1213    let local_commit = resolve_notes_commit_optional(repo, local_ref)?;
1214    let remote_commit = resolve_notes_commit_optional(repo, remote_ref)?;
1215    match (local_commit, remote_commit) {
1216        (None, None) => Err(Error::NotesMergeBothEmpty {
1217            local: local_ref.to_owned(),
1218            remote: remote_ref.to_owned(),
1219        }),
1220        (None, Some(r)) => Ok(Ok(r)),
1221        (Some(l), None) => Ok(Ok(l)),
1222        (Some(local_oid), Some(remote_oid)) => {
1223            if local_oid == remote_oid {
1224                return Ok(Ok(local_oid));
1225            }
1226            let bases = merge_bases_first_vs_rest(repo, local_oid, &[remote_oid])?;
1227            let base_commit = bases.into_iter().next();
1228            if Some(local_oid) == base_commit {
1229                return Ok(Ok(remote_oid));
1230            }
1231            if Some(remote_oid) == base_commit {
1232                return Ok(Ok(local_oid));
1233            }
1234            let base_tree = base_commit
1235                .map(|bc| resolve_commit_tree(repo, &bc))
1236                .transpose()?;
1237            let local_tree = resolve_commit_tree(repo, &local_oid)?;
1238            let remote_tree = resolve_commit_tree(repo, &remote_oid)?;
1239            let mut commit_msg = format!("Merged notes from {remote_ref} into {local_ref}\n\n");
1240            let pairs = build_merge_pairs(base_tree, local_tree, remote_tree, repo)?;
1241            let mut entries = Vec::new();
1242            let mut listable_seen = 0usize;
1243            let mut truncated = false;
1244            collect_notes_tree_entries(
1245                repo,
1246                &local_tree,
1247                b"",
1248                &mut entries,
1249                None,
1250                &mut listable_seen,
1251                &mut truncated,
1252            )?;
1253            debug_assert!(!truncated);
1254            let worktree = notes_merge_worktree_path(repo);
1255            let conflicts = merge_changes_into_entries(
1256                repo,
1257                &pairs,
1258                strategy,
1259                local_ref,
1260                remote_ref,
1261                &worktree,
1262                &mut commit_msg,
1263                &mut entries,
1264            )?;
1265            let merge_parents = vec![local_oid, remote_oid];
1266            if conflicts > 0 {
1267                let partial = write_notes_commit_with_parents(
1268                    repo,
1269                    local_ref,
1270                    &entries,
1271                    &commit_msg,
1272                    &merge_parents,
1273                    NotesRefUpdate::Leave,
1274                )?;
1275                return Ok(Err(partial));
1276            }
1277            let new_oid = write_notes_commit_with_parents(
1278                repo,
1279                local_ref,
1280                &entries,
1281                &commit_msg,
1282                &merge_parents,
1283                NotesRefUpdate::From(Some(local_oid)),
1284            )?;
1285            Ok(Ok(new_oid))
1286        }
1287    }
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292    use super::*;
1293    use crate::environment::RepositoryOptions;
1294    use crate::objects::ObjectKind;
1295    use crate::repo::Repository;
1296    use std::process::Command;
1297    use tempfile::TempDir;
1298
1299    fn git(dir: &std::path::Path, args: &[&str]) {
1300        let out = Command::new("git")
1301            .current_dir(dir)
1302            .args(args)
1303            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1304            .env("GIT_CONFIG_SYSTEM", "/dev/null")
1305            .env("GIT_AUTHOR_NAME", "Test")
1306            .env("GIT_AUTHOR_EMAIL", "t@example.com")
1307            .env("GIT_COMMITTER_NAME", "Test")
1308            .env("GIT_COMMITTER_EMAIL", "t@example.com")
1309            .output()
1310            .expect("git");
1311        assert!(
1312            out.status.success(),
1313            "git {:?}: {}",
1314            args,
1315            String::from_utf8_lossy(&out.stderr)
1316        );
1317    }
1318
1319    fn open_repo(dir: &std::path::Path) -> Repository {
1320        git(dir, &["init", "-q", "-b", "main", "."]);
1321        std::fs::write(dir.join("f"), b"x\n").unwrap();
1322        git(dir, &["add", "f"]);
1323        git(dir, &["commit", "-qm", "init"]);
1324        Repository::open(
1325            &dir.join(".git"),
1326            &RepositoryOptions::empty().with_work_tree(dir),
1327        )
1328        .expect("open")
1329    }
1330
1331    #[test]
1332    fn note_object_name_parses_flat_and_fanout_paths() {
1333        let full = "0123456789abcdef0123456789abcdef01234567";
1334        assert_eq!(note_object_name(full.as_bytes()), Some(full.to_owned()));
1335        assert_eq!(
1336            note_object_name(b"01/23/456789abcdef0123456789abcdef01234567"),
1337            Some(full.to_owned())
1338        );
1339        assert!(note_object_name(b"not-a-note").is_none());
1340    }
1341
1342    #[test]
1343    fn from_full_rejects_whitespace_padded_ref() {
1344        let err = NotesRef::from_full(" refs/notes/spaced ").expect_err("padded");
1345        assert!(matches!(err, Error::InvalidNotesRef(_)));
1346        let err = NotesRef::from_full("refs/notes/spaced ").expect_err("trailing");
1347        assert!(matches!(err, Error::InvalidNotesRef(_)));
1348        let ok = NotesRef::from_full("refs/notes/spaced").expect("valid");
1349        assert_eq!(ok.as_str(), "refs/notes/spaced");
1350    }
1351
1352    #[test]
1353    fn resolve_configured_notes_ref_rejects_empty_git_notes_ref() {
1354        use crate::config::ConfigSet;
1355        use std::collections::HashMap;
1356        use std::ffi::OsString;
1357
1358        struct MapEnv(HashMap<String, String>);
1359        impl crate::ident_resolve::IdentityEnv for MapEnv {
1360            fn var(&self, key: &str) -> Option<String> {
1361                self.0.get(key).cloned()
1362            }
1363            fn var_os(&self, key: &str) -> Option<OsString> {
1364                self.var(key).map(OsString::from)
1365            }
1366        }
1367
1368        let env = MapEnv(HashMap::from([("GIT_NOTES_REF".to_owned(), String::new())]));
1369        let err = resolve_configured_notes_ref(&env, &ConfigSet::default())
1370            .expect_err("empty GIT_NOTES_REF");
1371        assert!(matches!(err, Error::InvalidNotesRef(_)));
1372    }
1373
1374    #[test]
1375    fn expand_notes_ref_and_parse() {
1376        assert_eq!(expand_notes_ref("commits"), "refs/notes/commits");
1377        assert_eq!(expand_notes_ref("notes/foo"), "refs/notes/foo");
1378        assert_eq!(expand_notes_ref("refs/notes/bar"), "refs/notes/bar");
1379        assert_eq!(NotesRef::parse("review").as_str(), "refs/notes/review");
1380    }
1381
1382    #[test]
1383    fn notes_merge_worktree_nonempty_detects_files() {
1384        let tmp = TempDir::new().unwrap();
1385        let wt = tmp.path().join("wt");
1386        assert!(!notes_merge_worktree_nonempty(&wt));
1387        std::fs::create_dir_all(&wt).unwrap();
1388        assert!(!notes_merge_worktree_nonempty(&wt));
1389        std::fs::write(wt.join("x"), b"").unwrap();
1390        assert!(notes_merge_worktree_nonempty(&wt));
1391    }
1392
1393    #[test]
1394    fn combine_notes_helpers_and_upsert() {
1395        let tmp = TempDir::new().unwrap();
1396        let repo = open_repo(tmp.path());
1397        let a = repo.odb().write(ObjectKind::Blob, b"line1\n").unwrap();
1398        let b = repo.odb().write(ObjectKind::Blob, b"line2\n").unwrap();
1399        let cat = combine_notes_concatenate(&repo, Some(&a), Some(&b)).unwrap();
1400        let merged = repo.odb().read(&cat).unwrap();
1401        assert!(String::from_utf8_lossy(&merged.data).contains("line1"));
1402        let _uniq = combine_notes_cat_sort_uniq(&repo, Some(&a), Some(&b)).unwrap();
1403
1404        let hex = "0123456789abcdef0123456789abcdef01234567";
1405        let mut entries = Vec::new();
1406        upsert_note_entry(&mut entries, hex, a);
1407        upsert_note_entry(&mut entries, hex, b);
1408        assert_eq!(entries.len(), 1);
1409        assert_eq!(entries[0].oid, b);
1410    }
1411
1412    #[test]
1413    fn read_and_write_notes_roundtrip() {
1414        let tmp = TempDir::new().unwrap();
1415        let repo = open_repo(tmp.path());
1416        let head = Command::new("git")
1417            .current_dir(tmp.path())
1418            .args(["rev-parse", "HEAD"])
1419            .output()
1420            .unwrap();
1421        let commit = ObjectId::from_hex(std::str::from_utf8(&head.stdout).unwrap().trim()).unwrap();
1422        let blob = repo.odb().write(ObjectKind::Blob, b"hello\n").unwrap();
1423        upsert_note_entry(&mut vec![], &commit.to_hex(), blob);
1424        let entries = vec![NotesTreeEntry {
1425            mode: 0o100644,
1426            path: commit.to_hex().into_bytes(),
1427            oid: blob,
1428        }];
1429        write_notes_commit(&repo, "refs/notes/commits", &entries, "note").unwrap();
1430        let read_back = read_notes_tree(&repo, "refs/notes/commits").unwrap();
1431        assert_eq!(read_back.len(), 1);
1432        assert_eq!(read_back[0].oid, blob);
1433    }
1434
1435    #[test]
1436    fn notes_merge_ours_keeps_local_note() {
1437        let tmp = TempDir::new().unwrap();
1438        let repo = open_repo(tmp.path());
1439        let commit = ObjectId::from_hex(
1440            std::str::from_utf8(
1441                &Command::new("git")
1442                    .current_dir(tmp.path())
1443                    .args(["rev-parse", "HEAD"])
1444                    .output()
1445                    .unwrap()
1446                    .stdout,
1447            )
1448            .unwrap()
1449            .trim(),
1450        )
1451        .unwrap();
1452        git(
1453            tmp.path(),
1454            &["notes", "add", "-m", "base", &commit.to_hex()],
1455        );
1456        let base = crate::refs::resolve_ref(repo.git_dir(), "refs/notes/commits").unwrap();
1457        git(
1458            tmp.path(),
1459            &["notes", "add", "-f", "-m", "local", &commit.to_hex()],
1460        );
1461        let local = crate::refs::resolve_ref(repo.git_dir(), "refs/notes/commits").unwrap();
1462        git(
1463            tmp.path(),
1464            &["update-ref", "refs/notes/remote", &base.to_hex()],
1465        );
1466        git(
1467            tmp.path(),
1468            &[
1469                "notes",
1470                "--ref=refs/notes/remote",
1471                "add",
1472                "-f",
1473                "-m",
1474                "remote",
1475                &commit.to_hex(),
1476            ],
1477        );
1478        git(
1479            tmp.path(),
1480            &["update-ref", "refs/notes/commits", &local.to_hex()],
1481        );
1482        notes_merge_inner(
1483            &repo,
1484            "refs/notes/commits",
1485            "refs/notes/remote",
1486            NotesMergeStrategy::Ours,
1487        )
1488        .expect("merge")
1489        .expect("ok");
1490        let show = Command::new("git")
1491            .current_dir(tmp.path())
1492            .args(["notes", "show", &commit.to_hex()])
1493            .output()
1494            .unwrap();
1495        assert_eq!(String::from_utf8_lossy(&show.stdout).trim(), "local");
1496    }
1497
1498    #[test]
1499    fn notes_merge_manual_leaves_conflict_commit() {
1500        let tmp = TempDir::new().unwrap();
1501        let repo = open_repo(tmp.path());
1502        let commit = ObjectId::from_hex(
1503            std::str::from_utf8(
1504                &Command::new("git")
1505                    .current_dir(tmp.path())
1506                    .args(["rev-parse", "HEAD"])
1507                    .output()
1508                    .unwrap()
1509                    .stdout,
1510            )
1511            .unwrap()
1512            .trim(),
1513        )
1514        .unwrap();
1515        git(
1516            tmp.path(),
1517            &["notes", "add", "-m", "base", &commit.to_hex()],
1518        );
1519        let base = crate::refs::resolve_ref(repo.git_dir(), "refs/notes/commits").unwrap();
1520        git(
1521            tmp.path(),
1522            &["notes", "add", "-f", "-m", "local", &commit.to_hex()],
1523        );
1524        let local = crate::refs::resolve_ref(repo.git_dir(), "refs/notes/commits").unwrap();
1525        git(
1526            tmp.path(),
1527            &["update-ref", "refs/notes/remote", &base.to_hex()],
1528        );
1529        git(
1530            tmp.path(),
1531            &[
1532                "notes",
1533                "--ref=refs/notes/remote",
1534                "add",
1535                "-f",
1536                "-m",
1537                "remote",
1538                &commit.to_hex(),
1539            ],
1540        );
1541        git(
1542            tmp.path(),
1543            &["update-ref", "refs/notes/commits", &local.to_hex()],
1544        );
1545        let result = notes_merge_inner(
1546            &repo,
1547            "refs/notes/commits",
1548            "refs/notes/remote",
1549            NotesMergeStrategy::Manual,
1550        )
1551        .expect("merge");
1552        let partial = match result {
1553            Err(partial) => partial,
1554            Ok(_) => panic!("manual merge must stop with conflicts"),
1555        };
1556        assert_ne!(
1557            partial, local,
1558            "partial merge commit must differ from the pre-merge tip"
1559        );
1560        assert_eq!(
1561            crate::refs::resolve_ref(repo.git_dir(), "refs/notes/commits").unwrap(),
1562            local,
1563            "manual merge conflicts must leave the live notes ref at the local tip"
1564        );
1565        assert!(notes_merge_worktree_nonempty(&notes_merge_worktree_path(
1566            &repo
1567        )));
1568
1569        let err = notes_merge_inner(
1570            &repo,
1571            "refs/notes/commits",
1572            "refs/notes/remote",
1573            NotesMergeStrategy::Manual,
1574        )
1575        .expect_err("earlier conflicts are still unresolved");
1576        assert!(matches!(
1577            err,
1578            Error::NotesMergeInProgress { ref path } if *path == notes_merge_worktree_path(&repo)
1579        ));
1580    }
1581
1582    #[test]
1583    fn read_notes_tree_missing_ref_is_empty() {
1584        let tmp = TempDir::new().unwrap();
1585        let repo = open_repo(tmp.path());
1586        assert!(read_notes_tree(&repo, "refs/notes/commits")
1587            .unwrap()
1588            .is_empty());
1589    }
1590
1591    #[test]
1592    fn notes_merge_empty_remote_adopts_remote() {
1593        let tmp = TempDir::new().unwrap();
1594        let repo = open_repo(tmp.path());
1595        let local = write_notes_commit_with_parents(
1596            &repo,
1597            "refs/notes/commits",
1598            &[],
1599            "local only",
1600            &[],
1601            NotesRefUpdate::From(None),
1602        )
1603        .unwrap();
1604        let result = notes_merge_inner(
1605            &repo,
1606            "refs/notes/commits",
1607            "refs/notes/missing",
1608            NotesMergeStrategy::Theirs,
1609        )
1610        .expect("merge");
1611        assert_eq!(result.expect("ok"), local);
1612    }
1613
1614    #[test]
1615    fn notes_merge_of_two_empty_refs_is_typed() {
1616        let tmp = TempDir::new().unwrap();
1617        let repo = open_repo(tmp.path());
1618        let err = notes_merge_inner(
1619            &repo,
1620            "refs/notes/commits",
1621            "refs/notes/missing",
1622            NotesMergeStrategy::Union,
1623        )
1624        .expect_err("nothing to merge");
1625        assert!(matches!(
1626            err,
1627            Error::NotesMergeBothEmpty { ref local, ref remote }
1628                if local == "refs/notes/commits" && remote == "refs/notes/missing"
1629        ));
1630    }
1631
1632    #[test]
1633    fn read_notes_tree_rejects_blob_tip() {
1634        let tmp = TempDir::new().unwrap();
1635        let repo = open_repo(tmp.path());
1636        let blob = repo.odb().write(ObjectKind::Blob, b"not notes\n").unwrap();
1637        git(
1638            tmp.path(),
1639            &["update-ref", "refs/notes/blob", &blob.to_hex()],
1640        );
1641        let err = read_notes_tree(&repo, "refs/notes/blob").expect_err("blob tip");
1642        assert!(matches!(
1643            err,
1644            Error::NotesRefNotCommit { ref notes_ref, found: ObjectKind::Blob }
1645                if notes_ref == "refs/notes/blob"
1646        ));
1647        let err = notes_merge_inner(
1648            &repo,
1649            "refs/notes/commits",
1650            "refs/notes/blob",
1651            NotesMergeStrategy::Ours,
1652        )
1653        .expect_err("blob remote");
1654        assert!(matches!(
1655            err,
1656            Error::NotesRefNotCommit {
1657                found: ObjectKind::Blob,
1658                ..
1659            }
1660        ));
1661    }
1662
1663    #[test]
1664    fn write_notes_commit_goes_through_injected_ref_store() {
1665        let tmp = TempDir::new().unwrap();
1666        let repo = open_repo(tmp.path()).with_ref_store(std::sync::Arc::new(
1667            crate::refs::store::MemoryRefStore::new(),
1668        ));
1669        let blob = repo.odb().write(ObjectKind::Blob, b"in memory\n").unwrap();
1670        let mut entries = Vec::new();
1671        upsert_note_entry(&mut entries, &"ab".repeat(20), blob);
1672        write_notes_commit(&repo, "refs/notes/commits", &entries, "first").unwrap();
1673        let first = repo.refs().resolve("refs/notes/commits").unwrap();
1674        write_notes_commit(&repo, "refs/notes/commits", &[], "second").unwrap();
1675        let second = repo.refs().resolve("refs/notes/commits").unwrap();
1676
1677        let commit = parse_commit(&repo.odb().read(&second).unwrap().data).unwrap();
1678        assert_eq!(commit.parents, vec![first]);
1679        assert!(read_notes_tree(&repo, "refs/notes/commits")
1680            .unwrap()
1681            .is_empty());
1682        assert!(
1683            !tmp.path().join(".git/refs/notes/commits").exists(),
1684            "notes ref must live in the injected store, not on disk"
1685        );
1686    }
1687
1688    #[test]
1689    fn local_change_distinguishes_unchanged_deleted_and_set() {
1690        let base = ObjectId::from_hex(&"11".repeat(20)).unwrap();
1691        let other = ObjectId::from_hex(&"22".repeat(20)).unwrap();
1692        let pair = |local| NotesMergePair {
1693            obj: other,
1694            base_blob: Some(base),
1695            remote_blob: None,
1696            local,
1697        };
1698        assert_eq!(pair(LocalNoteState::Unset).local_change(), None);
1699        assert_eq!(pair(LocalNoteState::Present(base)).local_change(), None);
1700        assert_eq!(
1701            pair(LocalNoteState::Present(other)).local_change(),
1702            Some(Some(other))
1703        );
1704        assert_eq!(pair(LocalNoteState::Deleted).local_change(), Some(None));
1705    }
1706}