Skip to main content

grit_lib/
notes.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). The `grit` binary keeps argument parsing, editor launch,
13//! stdin reading, output, and exit-code mapping.
14
15use std::borrow::Cow;
16use std::collections::{BTreeMap, BTreeSet, HashMap};
17use std::fs;
18
19use merge3::{Merge3, StandardMarkers};
20use time::OffsetDateTime;
21
22use crate::config::ConfigSet;
23use crate::diff::zero_oid;
24use crate::error::{Error, Result};
25use crate::merge_base::merge_bases_first_vs_rest;
26use crate::objects::{
27    parse_commit, parse_tree, serialize_commit, serialize_tree, tree_entry_cmp, CommitData,
28    ObjectId, ObjectKind, TreeEntry,
29};
30use crate::refs::{append_reflog, resolve_ref, should_autocreate_reflog, write_ref};
31use crate::repo::Repository;
32use crate::rev_parse::resolve_revision;
33
34/// Per-worktree subdirectory holding the conflicted note blobs during a notes merge.
35pub const NOTES_MERGE_WORKTREE: &str = "NOTES_MERGE_WORKTREE";
36
37#[derive(Clone)]
38pub struct NotesTreeEntry {
39    pub mode: u32,
40    pub path: Vec<u8>,
41    pub oid: ObjectId,
42}
43
44enum NotesTreeChild {
45    Blob { mode: u32, oid: ObjectId },
46    Tree(Vec<NotesTreeEntry>),
47}
48
49pub fn note_object_name(path: &[u8]) -> Option<String> {
50    let compact: Vec<u8> = path.iter().copied().filter(|byte| *byte != b'/').collect();
51    if !ObjectId::is_hex_len(compact.len()) || !compact.iter().all(u8::is_ascii_hexdigit) {
52        return None;
53    }
54    String::from_utf8(compact)
55        .ok()
56        .map(|name| name.to_ascii_lowercase())
57}
58
59pub fn display_note_path(entry: &NotesTreeEntry) -> Cow<'_, str> {
60    if let Some(name) = note_object_name(&entry.path) {
61        Cow::Owned(name)
62    } else {
63        String::from_utf8_lossy(&entry.path)
64    }
65}
66
67fn collect_notes_tree_entries(
68    repo: &Repository,
69    tree_oid: &ObjectId,
70    prefix: &[u8],
71    out: &mut Vec<NotesTreeEntry>,
72) -> Result<()> {
73    let tree_obj = repo.odb.read(tree_oid)?;
74    if tree_obj.kind != ObjectKind::Tree {
75        return Err(Error::Message("notes commit has invalid tree".into()));
76    }
77
78    for entry in parse_tree(&tree_obj.data)? {
79        let mut path = prefix.to_vec();
80        if !path.is_empty() {
81            path.push(b'/');
82        }
83        path.extend_from_slice(&entry.name);
84
85        if entry.mode == 0o040000 {
86            collect_notes_tree_entries(repo, &entry.oid, &path, out)?;
87        } else {
88            out.push(NotesTreeEntry {
89                mode: entry.mode,
90                path,
91                oid: entry.oid,
92            });
93        }
94    }
95
96    Ok(())
97}
98
99/// Read the notes tree entries from the notes ref.  Returns an empty vec if
100/// the ref doesn't exist yet.
101pub fn read_notes_tree(repo: &Repository, notes_ref: &str) -> Result<Vec<NotesTreeEntry>> {
102    let tree_oid = match resolve_ref(&repo.git_dir, notes_ref) {
103        Ok(oid) => {
104            let obj = repo.odb.read(&oid)?;
105            match obj.kind {
106                ObjectKind::Commit => parse_commit(&obj.data)?.tree,
107                ObjectKind::Tree => oid,
108                _ => {
109                    return Err(Error::Message(format!(
110                        "{notes_ref} does not point to a commit or tree"
111                    )))
112                }
113            }
114        }
115        Err(_) => {
116            let oid = match resolve_revision(repo, notes_ref) {
117                Ok(o) => o,
118                Err(_) => return Ok(Vec::new()),
119            };
120            let obj = repo.odb.read(&oid)?;
121            match obj.kind {
122                ObjectKind::Commit => parse_commit(&obj.data)?.tree,
123                ObjectKind::Tree => oid,
124                _ => {
125                    return Err(Error::Message(format!(
126                        "{notes_ref} does not point to a commit or tree"
127                    )))
128                }
129            }
130        }
131    };
132    let mut entries = Vec::new();
133    collect_notes_tree_entries(repo, &tree_oid, b"", &mut entries)?;
134    Ok(entries)
135}
136
137fn notes_fanout(entries: &[NotesTreeEntry]) -> usize {
138    let mut note_count = entries
139        .iter()
140        .filter(|entry| note_object_name(&entry.path).is_some())
141        .count();
142    let mut fanout = 0usize;
143    while note_count > 0xff {
144        note_count >>= 8;
145        fanout += 1;
146    }
147    fanout
148}
149
150fn path_with_fanout(hex: &str, fanout: usize) -> Vec<u8> {
151    let mut path = Vec::with_capacity(hex.len() + fanout);
152    let bytes = hex.as_bytes();
153    let split = fanout.min(bytes.len() / 2);
154    for idx in 0..split {
155        let start = idx * 2;
156        path.extend_from_slice(&bytes[start..start + 2]);
157        path.push(b'/');
158    }
159    path.extend_from_slice(&bytes[split * 2..]);
160    path
161}
162
163fn write_notes_subtree(repo: &Repository, entries: &[NotesTreeEntry]) -> Result<ObjectId> {
164    let mut children: BTreeMap<Vec<u8>, NotesTreeChild> = BTreeMap::new();
165
166    for entry in entries {
167        if let Some(slash_pos) = entry.path.iter().position(|byte| *byte == b'/') {
168            let child_name = entry.path[..slash_pos].to_vec();
169            let child_entry = NotesTreeEntry {
170                mode: entry.mode,
171                path: entry.path[slash_pos + 1..].to_vec(),
172                oid: entry.oid,
173            };
174            children
175                .entry(child_name)
176                .or_insert_with(|| NotesTreeChild::Tree(Vec::new()));
177            if let Some(NotesTreeChild::Tree(tree_entries)) =
178                children.get_mut(&entry.path[..slash_pos])
179            {
180                tree_entries.push(child_entry);
181            }
182        } else {
183            children.insert(
184                entry.path.clone(),
185                NotesTreeChild::Blob {
186                    mode: entry.mode,
187                    oid: entry.oid,
188                },
189            );
190        }
191    }
192
193    let mut tree_entries = Vec::with_capacity(children.len());
194    for (name, child) in children {
195        match child {
196            NotesTreeChild::Blob { mode, oid } => tree_entries.push(TreeEntry { mode, name, oid }),
197            NotesTreeChild::Tree(child_entries) => {
198                let oid = write_notes_subtree(repo, &child_entries)?;
199                tree_entries.push(TreeEntry {
200                    mode: 0o040000,
201                    name,
202                    oid,
203                });
204            }
205        }
206    }
207
208    tree_entries
209        .sort_by(|a, b| tree_entry_cmp(&a.name, a.mode == 0o040000, &b.name, b.mode == 0o040000));
210
211    let tree_data = serialize_tree(&tree_entries);
212    repo.odb
213        .write(ObjectKind::Tree, &tree_data)
214        .map_err(Into::into)
215}
216
217/// Write a new notes tree and commit, updating the notes ref.
218pub fn write_notes_commit(
219    repo: &Repository,
220    notes_ref: &str,
221    entries: &[NotesTreeEntry],
222    message: &str,
223) -> Result<()> {
224    let fanout = notes_fanout(entries);
225    let rewritten_entries: Vec<_> = entries
226        .iter()
227        .map(|entry| NotesTreeEntry {
228            mode: entry.mode,
229            path: note_object_name(&entry.path)
230                .map(|name| path_with_fanout(&name, fanout))
231                .unwrap_or_else(|| entry.path.clone()),
232            oid: entry.oid,
233        })
234        .collect();
235    let tree_oid = write_notes_subtree(repo, &rewritten_entries)?;
236
237    // Get existing notes commit as parent (if any)
238    let parent = resolve_ref(&repo.git_dir, notes_ref).ok();
239
240    // Build committer/author ident
241    let config = ConfigSet::load(Some(&repo.git_dir), true)?;
242    let now = OffsetDateTime::now_utc();
243    let author = build_ident_role(&config, now, "AUTHOR");
244    let committer = build_ident_role(&config, now, "COMMITTER");
245
246    let commit = CommitData {
247        tree: tree_oid,
248        parents: parent.into_iter().collect(),
249        author,
250        committer: committer.clone(),
251        author_raw: Vec::new(),
252        committer_raw: Vec::new(),
253        encoding: None,
254        message: if message.ends_with('\n') {
255            message.to_owned()
256        } else {
257            format!("{message}\n")
258        },
259        raw_message: None,
260    };
261
262    let commit_data = serialize_commit(&commit);
263    let commit_oid = repo.odb.write(ObjectKind::Commit, &commit_data)?;
264
265    let old_oid = resolve_ref(&repo.git_dir, notes_ref).unwrap_or_else(|_| zero_oid());
266    write_ref(&repo.git_dir, notes_ref, &commit_oid)?;
267    if should_autocreate_reflog(&repo.git_dir, notes_ref) {
268        let msg = message.trim_end_matches('\n');
269        let reflog_msg = format!("notes: {msg}");
270        let _ = append_reflog(
271            &repo.git_dir,
272            notes_ref,
273            &old_oid,
274            &commit_oid,
275            &committer,
276            &reflog_msg,
277            false,
278        );
279    }
280    Ok(())
281}
282
283/// Build a Git ident string from config.
284/// Build an identity line for the notes commit, honoring the
285/// `GIT_{AUTHOR,COMMITTER}_{NAME,EMAIL,DATE}` environment variables exactly like
286/// `git notes` (and `git commit-tree`). `prefix` is either "AUTHOR" or "COMMITTER".
287fn build_ident_role(config: &ConfigSet, now: OffsetDateTime, prefix: &str) -> String {
288    let name_key = format!("GIT_{prefix}_NAME");
289    let email_key = format!("GIT_{prefix}_EMAIL");
290    let date_key = format!("GIT_{prefix}_DATE");
291
292    let name = std::env::var(&name_key)
293        .ok()
294        .filter(|n| !n.trim().is_empty())
295        .or_else(|| {
296            if prefix == "COMMITTER" {
297                std::env::var("GIT_AUTHOR_NAME")
298                    .ok()
299                    .filter(|n| !n.trim().is_empty())
300            } else {
301                None
302            }
303        })
304        .or_else(|| config.get("user.name"))
305        .unwrap_or_else(|| "Unknown".to_owned());
306
307    let email = std::env::var(&email_key)
308        .ok()
309        .filter(|e| !e.trim().is_empty())
310        .or_else(|| {
311            if prefix == "COMMITTER" {
312                std::env::var("GIT_AUTHOR_EMAIL")
313                    .ok()
314                    .filter(|e| !e.trim().is_empty())
315            } else {
316                None
317            }
318        })
319        .or_else(|| config.get("user.email"))
320        .unwrap_or_default();
321
322    let date = std::env::var(&date_key)
323        .ok()
324        .filter(|d| !d.trim().is_empty())
325        .and_then(|d| crate::commit::parse_date_to_git_timestamp(&d).or(Some(d)))
326        .unwrap_or_else(|| {
327            let epoch = now.unix_timestamp();
328            let offset = now.offset();
329            let hours = offset.whole_hours();
330            let minutes = offset.minutes_past_hour().unsigned_abs();
331            format!("{epoch} {hours:+03}{minutes:02}")
332        });
333
334    format!("{name} <{email}> {date}")
335}
336
337/// Per-worktree git directory for `NOTES_MERGE_*` (main: `.git/`, linked: `.git/worktrees/<id>/`).
338pub fn notes_merge_git_dir(repo: &Repository) -> std::path::PathBuf {
339    repo.git_dir.clone()
340}
341
342#[derive(Clone, Copy, Debug, PartialEq, Eq)]
343pub enum NotesMergeStrategy {
344    Manual,
345    Ours,
346    Theirs,
347    Union,
348    CatSortUniq,
349}
350
351#[derive(Clone, Debug)]
352enum LocalNoteState {
353    Unset,
354    Deleted,
355    Present(ObjectId),
356}
357
358#[derive(Clone, Debug)]
359struct NotesMergePair {
360    obj: ObjectId,
361    base_blob: Option<ObjectId>,
362    remote_blob: Option<ObjectId>,
363    local: LocalNoteState,
364}
365
366/// Match Git's `expand_notes_ref` (`notes.c`): only `--ref` uses this; env/config refs are verbatim.
367pub fn expand_notes_ref(short_or_full: &str) -> String {
368    if short_or_full.starts_with("refs/notes/") {
369        short_or_full.to_owned()
370    } else if short_or_full.starts_with("notes/") {
371        format!("refs/{short_or_full}")
372    } else {
373        format!("refs/notes/{short_or_full}")
374    }
375}
376
377pub fn notes_merge_worktree_path(repo: &Repository) -> std::path::PathBuf {
378    notes_merge_git_dir(repo).join(NOTES_MERGE_WORKTREE)
379}
380
381/// True when `NOTES_MERGE_WORKTREE` exists and is not empty (matches Git `is_empty_dir`).
382pub fn notes_merge_worktree_nonempty(worktree: &std::path::Path) -> bool {
383    if !worktree.is_dir() {
384        return false;
385    }
386    let Ok(entries) = fs::read_dir(worktree) else {
387        return false;
388    };
389    entries.flatten().next().is_some()
390}
391
392pub fn parse_notes_merge_strategy_value(s: &str) -> Option<NotesMergeStrategy> {
393    match s {
394        "manual" => Some(NotesMergeStrategy::Manual),
395        "ours" => Some(NotesMergeStrategy::Ours),
396        "theirs" => Some(NotesMergeStrategy::Theirs),
397        "union" => Some(NotesMergeStrategy::Union),
398        "cat_sort_uniq" => Some(NotesMergeStrategy::CatSortUniq),
399        _ => None,
400    }
401}
402
403/// Map annotated object → note blob OID for one notes tree (any fanout layout).
404fn notes_tree_blob_by_object(
405    repo: &Repository,
406    tree_oid: &ObjectId,
407) -> Result<HashMap<ObjectId, ObjectId>> {
408    let mut flat = Vec::new();
409    collect_notes_tree_entries(repo, tree_oid, b"", &mut flat)?;
410    let mut map = HashMap::new();
411    for entry in flat {
412        let Some(hex) = note_object_name(&entry.path) else {
413            continue;
414        };
415        let obj = ObjectId::from_hex(&hex)
416            .map_err(|e| Error::Message(format!("invalid note object id in tree: {e}")))?;
417        map.insert(obj, entry.oid);
418    }
419    Ok(map)
420}
421
422fn diff_note_blob_changes(
423    repo: &Repository,
424    old_tree: Option<&ObjectId>,
425    new_tree: Option<&ObjectId>,
426) -> Result<Vec<(ObjectId, Option<ObjectId>, Option<ObjectId>)>> {
427    let old_map = match old_tree {
428        Some(t) => notes_tree_blob_by_object(repo, t)?,
429        None => HashMap::new(),
430    };
431    let new_map = match new_tree {
432        Some(t) => notes_tree_blob_by_object(repo, t)?,
433        None => HashMap::new(),
434    };
435    let keys: BTreeSet<ObjectId> = old_map.keys().chain(new_map.keys()).copied().collect();
436    let mut out = Vec::new();
437    for obj in keys {
438        let o_old = old_map.get(&obj).copied();
439        let o_new = new_map.get(&obj).copied();
440        match (o_old, o_new) {
441            (None, Some(new_b)) => out.push((obj, None, Some(new_b))),
442            (Some(old_b), None) => out.push((obj, Some(old_b), None)),
443            (Some(old_b), Some(new_b)) if old_b != new_b => {
444                out.push((obj, Some(old_b), Some(new_b)));
445            }
446            _ => {}
447        }
448    }
449    Ok(out)
450}
451
452fn build_merge_pairs(
453    base_tree: Option<ObjectId>,
454    local_tree: ObjectId,
455    remote_tree: ObjectId,
456    repo: &Repository,
457) -> Result<Vec<NotesMergePair>> {
458    let remote_raw = diff_note_blob_changes(repo, base_tree.as_ref(), Some(&remote_tree))?;
459    let local_raw = diff_note_blob_changes(repo, base_tree.as_ref(), Some(&local_tree))?;
460    let mut map: HashMap<ObjectId, NotesMergePair> = HashMap::new();
461    for (obj, old_b, new_b) in remote_raw {
462        map.insert(
463            obj,
464            NotesMergePair {
465                obj,
466                base_blob: old_b,
467                remote_blob: new_b,
468                local: LocalNoteState::Unset,
469            },
470        );
471    }
472
473    for (obj, old_b, new_b) in local_raw {
474        let local_state = match new_b {
475            Some(new_oid) => LocalNoteState::Present(new_oid),
476            None => LocalNoteState::Deleted,
477        };
478        if let Some(p) = map.get_mut(&obj) {
479            p.local = local_state;
480        } else {
481            map.insert(
482                obj,
483                NotesMergePair {
484                    obj,
485                    base_blob: old_b,
486                    remote_blob: old_b,
487                    local: local_state,
488                },
489            );
490        }
491    }
492
493    let mut v: Vec<_> = map.into_values().collect();
494    v.sort_by(|a, b| a.obj.cmp(&b.obj));
495    Ok(v)
496}
497
498fn read_blob_bytes(repo: &Repository, oid: &ObjectId) -> Result<Vec<u8>> {
499    let obj = repo.odb.read(oid)?;
500    if obj.kind != ObjectKind::Blob {
501        return Err(Error::Message("expected blob for note".into()));
502    }
503    Ok(obj.data)
504}
505
506/// Matches Git's `combine_notes_concatenate`: join two note blobs with a blank line between them.
507pub fn combine_notes_concatenate(
508    repo: &Repository,
509    cur: Option<&ObjectId>,
510    new_oid: Option<&ObjectId>,
511) -> Result<ObjectId> {
512    let new_data = match new_oid {
513        Some(n) => {
514            let obj = repo.odb.read(n)?;
515            if obj.kind != ObjectKind::Blob || obj.data.is_empty() {
516                Vec::new()
517            } else {
518                obj.data
519            }
520        }
521        None => Vec::new(),
522    };
523    if new_data.is_empty() {
524        let Some(c) = cur else {
525            return Err(Error::Message(
526                "combine_notes_concatenate: empty new and no current".into(),
527            ));
528        };
529        return Ok(*c);
530    }
531
532    let cur_data = match cur {
533        Some(c) => {
534            let obj = repo.odb.read(c)?;
535            if obj.kind != ObjectKind::Blob || obj.data.is_empty() {
536                Vec::new()
537            } else {
538                obj.data
539            }
540        }
541        None => Vec::new(),
542    };
543
544    if cur_data.is_empty() {
545        return Ok(repo.odb.write(ObjectKind::Blob, &new_data)?);
546    }
547
548    let mut cur_len = cur_data.len();
549    if cur_len > 0 && cur_data[cur_len - 1] == b'\n' {
550        cur_len -= 1;
551    }
552    let mut buf = Vec::with_capacity(cur_len + 2 + new_data.len());
553    buf.extend_from_slice(&cur_data[..cur_len]);
554    buf.push(b'\n');
555    buf.push(b'\n');
556    buf.extend_from_slice(&new_data);
557    Ok(repo.odb.write(ObjectKind::Blob, &buf)?)
558}
559
560fn note_blob_lines(data: &[u8]) -> Vec<String> {
561    if data.is_empty() {
562        return Vec::new();
563    }
564    let s = String::from_utf8_lossy(data);
565    s.split('\n').map(|l| l.to_owned()).collect()
566}
567
568/// Matches Git's `combine_notes_cat_sort_uniq`: all lines from both blobs, de-duplicated and sorted.
569pub fn combine_notes_cat_sort_uniq(
570    repo: &Repository,
571    cur: Option<&ObjectId>,
572    new_oid: Option<&ObjectId>,
573) -> Result<ObjectId> {
574    let mut lines: Vec<String> = Vec::new();
575    for oid in [cur, new_oid].into_iter().flatten() {
576        let obj = repo.odb.read(oid)?;
577        if obj.kind == ObjectKind::Blob && !obj.data.is_empty() {
578            lines.extend(note_blob_lines(&obj.data));
579        }
580    }
581    lines.retain(|l| !l.is_empty());
582    lines.sort();
583    lines.dedup();
584    let mut buf = String::new();
585    for l in &lines {
586        buf.push_str(l);
587        buf.push('\n');
588    }
589    Ok(repo.odb.write(ObjectKind::Blob, buf.as_bytes())?)
590}
591
592fn blob_to_lines(data: &[u8]) -> Vec<String> {
593    if data.is_empty() {
594        return vec![String::new()];
595    }
596    let s = String::from_utf8_lossy(data).into_owned();
597    s.split_inclusive('\n').map(|l| l.to_owned()).collect()
598}
599
600fn merge_note_blobs_conflict_markers(
601    repo: &Repository,
602    base: Option<&ObjectId>,
603    local: &ObjectId,
604    remote: &ObjectId,
605    local_ref: &str,
606    remote_ref: &str,
607) -> Result<Vec<u8>> {
608    let base_lines: Vec<String> = match base {
609        Some(b) => blob_to_lines(&read_blob_bytes(repo, b)?),
610        None => vec![String::new()],
611    };
612    let local_lines = blob_to_lines(&read_blob_bytes(repo, local)?);
613    let remote_lines = blob_to_lines(&read_blob_bytes(repo, remote)?);
614
615    let base_refs: Vec<&str> = base_lines.iter().map(|s| s.as_str()).collect();
616    let local_refs: Vec<&str> = local_lines.iter().map(|s| s.as_str()).collect();
617    let remote_refs: Vec<&str> = remote_lines.iter().map(|s| s.as_str()).collect();
618    let m3 = Merge3::new(&base_refs, &local_refs, &remote_refs);
619    let markers = StandardMarkers::new(Some(local_ref), Some(remote_ref));
620    let merged: String = m3
621        .merge_lines(false, &markers)
622        .into_iter()
623        .map(|cow| cow.into_owned())
624        .collect();
625    Ok(merged.into_bytes())
626}
627
628fn write_note_conflict_file(
629    path: &std::path::Path,
630    repo: &Repository,
631    pair: &NotesMergePair,
632    local_ref: &str,
633    remote_ref: &str,
634) -> Result<()> {
635    let data = match (&pair.local, &pair.remote_blob) {
636        (LocalNoteState::Deleted, Some(r)) => read_blob_bytes(repo, r)?,
637        (LocalNoteState::Present(l), None) => read_blob_bytes(repo, l)?,
638        (LocalNoteState::Present(l), Some(r)) => merge_note_blobs_conflict_markers(
639            repo,
640            pair.base_blob.as_ref(),
641            l,
642            r,
643            local_ref,
644            remote_ref,
645        )?,
646        _ => {
647            return Err(Error::Message(
648                "unexpected notes merge conflict shape".into(),
649            ))
650        }
651    };
652    fs::write(path, data)?;
653    Ok(())
654}
655
656fn merge_one_note_change(
657    repo: &Repository,
658    pair: &NotesMergePair,
659    strategy: NotesMergeStrategy,
660    local_ref: &str,
661    remote_ref: &str,
662    worktree: &std::path::Path,
663    commit_msg: &mut String,
664    entries: &mut Vec<NotesTreeEntry>,
665    has_worktree: &mut bool,
666) -> Result<bool> {
667    let obj_hex = pair.obj.to_hex();
668    let path = worktree.join(&obj_hex);
669    match strategy {
670        NotesMergeStrategy::Manual => {
671            if !*has_worktree && notes_merge_worktree_nonempty(worktree) {
672                return Err(Error::Message(
673                    "You have not concluded your previous notes merge (.git/NOTES_MERGE_* exists).\n\
674Please, use 'git notes merge --commit' or 'git notes merge --abort' to commit/abort the \
675previous merge before you start a new notes merge."
676                        .into(),
677                ));
678            }
679            if !commit_msg.contains("Conflicts:") {
680                commit_msg.push_str("\n\nConflicts:\n");
681            }
682            commit_msg.push_str(&format!("\t{obj_hex}\n"));
683            if !*has_worktree {
684                let test = worktree.join(".test");
685                fs::create_dir_all(worktree)?;
686                fs::write(&test, b"")?;
687                let _ = fs::remove_file(test);
688                *has_worktree = true;
689            }
690            write_note_conflict_file(&path, repo, pair, local_ref, remote_ref)?;
691            entries.retain(|e| note_object_name(&e.path).as_deref() != Some(obj_hex.as_str()));
692            Ok(true)
693        }
694        NotesMergeStrategy::Ours => Ok(false),
695        NotesMergeStrategy::Theirs => {
696            if let Some(r) = pair.remote_blob {
697                upsert_note_entry(entries, &obj_hex, r);
698            } else {
699                entries.retain(|e| note_object_name(&e.path).as_deref() != Some(obj_hex.as_str()));
700            }
701            Ok(false)
702        }
703        NotesMergeStrategy::Union => {
704            match (&pair.local, &pair.remote_blob) {
705                (LocalNoteState::Deleted, None) => {
706                    entries
707                        .retain(|e| note_object_name(&e.path).as_deref() != Some(obj_hex.as_str()));
708                }
709                (LocalNoteState::Deleted, Some(r)) => {
710                    let out = combine_notes_concatenate(repo, None, Some(r))?;
711                    upsert_note_entry(entries, &obj_hex, out);
712                }
713                (LocalNoteState::Present(_), None) => {}
714                (LocalNoteState::Present(l), Some(r)) => {
715                    let out = combine_notes_concatenate(repo, Some(l), Some(r))?;
716                    upsert_note_entry(entries, &obj_hex, out);
717                }
718                (LocalNoteState::Unset, _) => {
719                    return Err(Error::Message(
720                        "unexpected notes merge pair: local unset in union strategy".into(),
721                    ));
722                }
723            }
724            Ok(false)
725        }
726        NotesMergeStrategy::CatSortUniq => {
727            match (&pair.local, &pair.remote_blob) {
728                (LocalNoteState::Deleted, None) => {
729                    entries
730                        .retain(|e| note_object_name(&e.path).as_deref() != Some(obj_hex.as_str()));
731                }
732                (LocalNoteState::Deleted, Some(r)) => {
733                    let out = combine_notes_cat_sort_uniq(repo, None, Some(r))?;
734                    upsert_note_entry(entries, &obj_hex, out);
735                }
736                (LocalNoteState::Present(_), None) => {}
737                (LocalNoteState::Present(l), Some(r)) => {
738                    let out = combine_notes_cat_sort_uniq(repo, Some(l), Some(r))?;
739                    upsert_note_entry(entries, &obj_hex, out);
740                }
741                (LocalNoteState::Unset, _) => {
742                    return Err(Error::Message(
743                        "unexpected notes merge pair: local unset in cat_sort_uniq strategy".into(),
744                    ));
745                }
746            }
747            Ok(false)
748        }
749    }
750}
751
752pub fn upsert_note_entry(entries: &mut Vec<NotesTreeEntry>, hex: &str, blob: ObjectId) {
753    entries.retain(|e| note_object_name(&e.path).as_deref() != Some(hex));
754    entries.push(NotesTreeEntry {
755        mode: 0o100644,
756        path: hex.as_bytes().to_vec(),
757        oid: blob,
758    });
759}
760
761fn remote_unchanged(base: Option<ObjectId>, remote: Option<ObjectId>) -> bool {
762    match (base, remote) {
763        (Some(b), Some(r)) => b == r,
764        (None, None) => true,
765        _ => false,
766    }
767}
768
769fn same_change_local_remote(p: &NotesMergePair) -> bool {
770    match (&p.local, p.remote_blob) {
771        (LocalNoteState::Present(l), Some(r)) => l == &r,
772        (LocalNoteState::Deleted, None) => true,
773        (LocalNoteState::Unset, Some(r)) => Some(r) == p.base_blob,
774        (LocalNoteState::Unset, None) => p.base_blob.is_none(),
775        _ => false,
776    }
777}
778
779fn no_local_change(local: &LocalNoteState, base: Option<ObjectId>) -> bool {
780    match local {
781        LocalNoteState::Unset => true,
782        LocalNoteState::Present(l) => Some(*l) == base,
783        LocalNoteState::Deleted => false,
784    }
785}
786
787fn adopt_remote_note(entries: &mut Vec<NotesTreeEntry>, obj_hex: &str, remote: Option<ObjectId>) {
788    match remote {
789        Some(oid) => upsert_note_entry(entries, obj_hex, oid),
790        None => entries.retain(|e| note_object_name(&e.path).as_deref() != Some(obj_hex)),
791    }
792}
793
794fn merge_changes_into_entries(
795    repo: &Repository,
796    pairs: &[NotesMergePair],
797    strategy: NotesMergeStrategy,
798    local_ref: &str,
799    remote_ref: &str,
800    worktree: &std::path::Path,
801    commit_msg: &mut String,
802    entries: &mut Vec<NotesTreeEntry>,
803) -> Result<usize> {
804    let mut conflicts = 0usize;
805    let mut has_worktree = false;
806    for p in pairs {
807        if remote_unchanged(p.base_blob, p.remote_blob) {
808            continue;
809        }
810        if same_change_local_remote(p) {
811            continue;
812        }
813        if no_local_change(&p.local, p.base_blob) {
814            adopt_remote_note(entries, &p.obj.to_hex(), p.remote_blob);
815            continue;
816        }
817        if merge_one_note_change(
818            repo,
819            p,
820            strategy,
821            local_ref,
822            remote_ref,
823            worktree,
824            commit_msg,
825            entries,
826            &mut has_worktree,
827        )? {
828            conflicts += 1;
829        }
830    }
831    Ok(conflicts)
832}
833
834fn resolve_commit_tree(repo: &Repository, commit_oid: &ObjectId) -> Result<ObjectId> {
835    let obj = repo.odb.read(commit_oid)?;
836    if obj.kind != ObjectKind::Commit {
837        return Err(Error::Message("expected commit".into()));
838    }
839    Ok(parse_commit(&obj.data)?.tree)
840}
841
842fn resolve_notes_commit_optional(repo: &Repository, notes_ref: &str) -> Result<Option<ObjectId>> {
843    let oid = match resolve_ref(&repo.git_dir, notes_ref) {
844        Ok(o) => o,
845        Err(_) => return Ok(None),
846    };
847    let obj = repo.odb.read(&oid)?;
848    if obj.kind != ObjectKind::Commit {
849        return Err(Error::Message(format!(
850            "{notes_ref} does not point to a commit"
851        )));
852    }
853    Ok(Some(oid))
854}
855
856pub fn write_notes_commit_with_parents(
857    repo: &Repository,
858    _notes_ref: &str,
859    entries: &[NotesTreeEntry],
860    message: &str,
861    parents: &[ObjectId],
862) -> Result<ObjectId> {
863    let fanout = notes_fanout(entries);
864    let rewritten_entries: Vec<_> = entries
865        .iter()
866        .map(|entry| NotesTreeEntry {
867            mode: entry.mode,
868            path: note_object_name(&entry.path)
869                .map(|name| path_with_fanout(&name, fanout))
870                .unwrap_or_else(|| entry.path.clone()),
871            oid: entry.oid,
872        })
873        .collect();
874    let tree_oid = write_notes_subtree(repo, &rewritten_entries)?;
875    let config = ConfigSet::load(Some(&repo.git_dir), true)?;
876    let now = OffsetDateTime::now_utc();
877    let author = build_ident_role(&config, now, "AUTHOR");
878    let committer = build_ident_role(&config, now, "COMMITTER");
879    let commit = CommitData {
880        tree: tree_oid,
881        parents: parents.to_vec(),
882        author,
883        committer,
884        author_raw: Vec::new(),
885        committer_raw: Vec::new(),
886        encoding: None,
887        message: if message.ends_with('\n') {
888            message.to_owned()
889        } else {
890            format!("{message}\n")
891        },
892        raw_message: None,
893    };
894    let commit_data = serialize_commit(&commit);
895    Ok(repo.odb.write(ObjectKind::Commit, &commit_data)?)
896}
897
898pub fn notes_merge_inner(
899    repo: &Repository,
900    local_ref: &str,
901    remote_ref: &str,
902    strategy: NotesMergeStrategy,
903) -> Result<std::result::Result<ObjectId, ObjectId>> {
904    let local_commit = resolve_notes_commit_optional(repo, local_ref)?;
905    let remote_commit = resolve_notes_commit_optional(repo, remote_ref)?;
906    match (local_commit, remote_commit) {
907        (None, None) => {
908            return Err(Error::Message(format!(
909                "Cannot merge empty notes ref ({remote_ref}) into empty notes ref ({local_ref})"
910            )))
911        }
912        (None, Some(r)) => Ok(Ok(r)),
913        (Some(l), None) => Ok(Ok(l)),
914        (Some(local_oid), Some(remote_oid)) => {
915            if local_oid == remote_oid {
916                return Ok(Ok(local_oid));
917            }
918            let bases = merge_bases_first_vs_rest(repo, local_oid, &[remote_oid])?;
919            let base_commit = bases.into_iter().next();
920            if Some(local_oid) == base_commit {
921                return Ok(Ok(remote_oid));
922            }
923            if Some(remote_oid) == base_commit {
924                return Ok(Ok(local_oid));
925            }
926            let base_tree = base_commit
927                .map(|bc| resolve_commit_tree(repo, &bc))
928                .transpose()?;
929            let local_tree = resolve_commit_tree(repo, &local_oid)?;
930            let remote_tree = resolve_commit_tree(repo, &remote_oid)?;
931            let mut commit_msg = format!("Merged notes from {remote_ref} into {local_ref}\n\n");
932            let pairs = build_merge_pairs(base_tree, local_tree, remote_tree, repo)?;
933            let mut entries = read_notes_tree(repo, local_ref)?;
934            let worktree = notes_merge_worktree_path(repo);
935            let conflicts = merge_changes_into_entries(
936                repo,
937                &pairs,
938                strategy,
939                local_ref,
940                remote_ref,
941                &worktree,
942                &mut commit_msg,
943                &mut entries,
944            )?;
945            let merge_parents = vec![local_oid, remote_oid];
946            if conflicts > 0 {
947                let partial = write_notes_commit_with_parents(
948                    repo,
949                    local_ref,
950                    &entries,
951                    &commit_msg,
952                    &merge_parents,
953                )?;
954                return Ok(Err(partial));
955            }
956            let new_oid = write_notes_commit_with_parents(
957                repo,
958                local_ref,
959                &entries,
960                &commit_msg,
961                &merge_parents,
962            )?;
963            Ok(Ok(new_oid))
964        }
965    }
966}