Skip to main content

grit_lib/repo_handles/
notes.rs

1//! Git notes scoped to a [`Repository`](crate::repo::Repository) and a [`NotesRef`](crate::notes::NotesRef).
2
3use crate::error::Result;
4use crate::ident::Signature;
5use crate::list_limit::{fetch_plus_one, Limited};
6use crate::notes::{
7    note_object_name, preview_notes_commit, read_blob_bytes, read_notes_snapshot, read_notes_tree,
8    read_notes_tree_limited, remove_note_entry, upsert_note_entry, write_notes_commit_idents,
9    NotesCommit, NotesRef, NotesSnapshot, NotesTreeEntry,
10};
11use crate::objects::{ObjectId, ObjectKind};
12use crate::porcelain::merge::MemOverlayGuard;
13use crate::repo::Repository;
14
15/// Author, committer, and commit message for a notes ref update.
16#[derive(Debug, Clone)]
17pub struct NotesWriteOptions {
18    /// First line and body of the notes commit (trailing newline optional).
19    pub message: String,
20    /// Committer of the notes commit (for example from [`Repository::identity`]).
21    pub committer: Signature,
22    /// Author of the notes commit; defaults to [`Self::committer`] when `None`.
23    pub author: Option<Signature>,
24}
25
26impl NotesWriteOptions {
27    /// Validated `(author, committer)` header lines for the notes commit.
28    fn header_lines(&self) -> Result<(String, String)> {
29        let committer = &self.committer;
30        let author = self.author.as_ref().unwrap_or(committer);
31        for signature in [author, committer] {
32            signature.validate()?;
33        }
34        Ok((author.header_line(), committer.header_line()))
35    }
36}
37
38/// One `(annotated object, note blob)` pair from a notes tree.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct NoteListing {
41    /// Object the note is attached to.
42    pub object: ObjectId,
43    /// Blob object id holding note bytes.
44    pub note_blob: ObjectId,
45}
46
47/// One note with its contents, from [`RepositoryNotes::list_with_bodies`].
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Note {
50    /// Object the note is attached to.
51    pub object: ObjectId,
52    /// Blob object id holding the note bytes.
53    pub note_blob: ObjectId,
54    /// Note bytes exactly as stored (Git notes are usually UTF-8 text ending in `\n`).
55    pub body: Vec<u8>,
56}
57
58/// Preview of [`RepositoryNotes::add`] without writing objects, refs, or reflogs.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct NoteAddPlan {
61    /// Annotated object the note would attach to.
62    pub object: ObjectId,
63    /// Whether the note would be newly added or would replace an existing note.
64    pub outcome: NoteAddOutcome,
65    /// Notes ref tip before the operation (`None` when the ref did not exist).
66    pub notes_ref_tip_before: Option<ObjectId>,
67    /// Predicted notes ref tip after the operation.
68    pub notes_ref_tip_after: ObjectId,
69    /// Blob id that would store the note bytes.
70    pub note_blob: ObjectId,
71}
72
73/// Preview of removing a note on one object without writing objects, refs, or reflogs.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct NoteRemovePlan {
76    /// Annotated object whose note would be removed.
77    pub object: ObjectId,
78    /// Whether a note exists on `object` today.
79    pub had_note: bool,
80    /// Notes ref tip before the operation (`None` when the ref did not exist).
81    pub notes_ref_tip_before: Option<ObjectId>,
82    /// Predicted notes ref tip after removal (`None` when nothing would change).
83    pub notes_ref_tip_after: Option<ObjectId>,
84}
85
86/// Preview of [`RepositoryNotes::remove_many`] without writing objects, refs, or reflogs.
87///
88/// The removals compose: [`Self::notes_ref_tip_after`] is the single notes commit that
89/// `remove_many` would write for the same objects and [`NotesWriteOptions`].
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct NoteRemoveManyPlan {
92    /// Objects whose notes would be removed, in request order. Objects without a note and
93    /// repeated objects are left out, matching the return value of `remove_many`.
94    pub removed: Vec<ObjectId>,
95    /// Notes ref tip before the operation (`None` when the ref did not exist).
96    pub notes_ref_tip_before: Option<ObjectId>,
97    /// Predicted notes ref tip after removal (`None` when no note would be removed).
98    pub notes_ref_tip_after: Option<ObjectId>,
99}
100
101/// Notes tree with removals applied in memory, shared by planning and writing.
102struct StagedRemoval {
103    tip: Option<ObjectId>,
104    entries: Vec<NotesTreeEntry>,
105    removed: Vec<ObjectId>,
106}
107
108/// What [`RepositoryNotes::add`] did to the annotated object's note.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum NoteAddOutcome {
111    /// The object had no note; one was attached.
112    Added,
113    /// The object already had a note and `force` replaced it.
114    Replaced,
115}
116
117/// Note entries of a notes tree whose path names an object (other entries are skipped).
118fn listings(entries: Vec<NotesTreeEntry>) -> impl Iterator<Item = NoteListing> {
119    entries.into_iter().filter_map(|e| {
120        let object = note_object_name(&e.path)?.parse().ok()?;
121        Some(NoteListing {
122            object,
123            note_blob: e.oid,
124        })
125    })
126}
127
128/// Notes read/write for one open repository (default ref: [`NotesRef::commits`]).
129#[derive(Debug, Copy, Clone)]
130pub struct RepositoryNotes<'repo> {
131    repo: &'repo Repository,
132}
133
134impl<'repo> RepositoryNotes<'repo> {
135    pub(crate) fn new(repo: &'repo Repository) -> Self {
136        Self { repo }
137    }
138
139    /// Borrow the underlying repository.
140    #[must_use]
141    pub fn repository(&self) -> &'repo Repository {
142        self.repo
143    }
144
145    /// List notes under `notes_ref`.
146    ///
147    /// # Errors
148    ///
149    /// Propagates notes tree read failures.
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// # use grit_lib::notes::NotesRef;
155    /// # use grit_lib::repo::Repository;
156    /// # use std::path::Path;
157    /// # fn demo() -> grit_lib::error::Result<()> {
158    /// let repo = Repository::discover(Some(Path::new(".")), &grit_lib::config::environment::RepositoryOptions::empty())?;
159    /// let notes = repo.notes().list(&NotesRef::commits(), None)?;
160    /// let _ = notes.items.len();
161    /// # Ok(())
162    /// # }
163    /// ```
164    pub fn list(&self, notes_ref: &NotesRef, limit: Option<usize>) -> Result<Limited<NoteListing>> {
165        let cap = fetch_plus_one(limit);
166        let (entries, stopped_early) = read_notes_tree_limited(self.repo, notes_ref.as_str(), cap)?;
167        let notes: Vec<NoteListing> = listings(entries).collect();
168        let mut limited = Limited::from_vec(notes, limit);
169        if stopped_early {
170            limited.truncated = true;
171        }
172        Ok(limited)
173    }
174
175    /// List notes under `notes_ref` together with their contents, sorted by annotated object.
176    ///
177    /// # Errors
178    ///
179    /// - Notes tree read failures (see [`Self::list`]).
180    /// - [`Error::NotesObjectKind`](crate::error::Error::NotesObjectKind) when a note entry is
181    ///   not a blob, and object read failures (a missing note blob is an error, not an empty
182    ///   note).
183    pub fn list_with_bodies(
184        &self,
185        notes_ref: &NotesRef,
186        limit: Option<usize>,
187    ) -> Result<Limited<Note>> {
188        let listed = self.list(notes_ref, limit)?;
189        let mut notes = Vec::with_capacity(listed.items.len());
190        for listing in listed.items {
191            notes.push(Note {
192                body: read_blob_bytes(self.repo, &listing.note_blob)?,
193                object: listing.object,
194                note_blob: listing.note_blob,
195            });
196        }
197        notes.sort_by_key(|note| note.object);
198        Ok(Limited {
199            items: notes,
200            truncated: listed.truncated,
201        })
202    }
203
204    /// Read note bytes for `object`, if present.
205    ///
206    /// # Errors
207    ///
208    /// Propagates notes tree and object read failures;
209    /// [`Error::NotesObjectKind`](crate::error::Error::NotesObjectKind) when the note entry is
210    /// not a blob.
211    pub fn read(&self, notes_ref: &NotesRef, object: &ObjectId) -> Result<Option<Vec<u8>>> {
212        let hex = object.to_hex();
213        let entries = read_notes_tree(self.repo, notes_ref.as_str())?;
214        let Some(entry) = entries
215            .iter()
216            .find(|e| note_object_name(&e.path).as_deref() == Some(hex.as_str()))
217        else {
218            return Ok(None);
219        };
220        read_blob_bytes(self.repo, &entry.oid).map(Some)
221    }
222
223    /// Preview [`Self::add`] without persisting blobs, trees, commits, or refs.
224    ///
225    /// Object writes are confined to the ODB in-memory overlay and discarded before return.
226    ///
227    /// # Errors
228    ///
229    /// Same as [`Self::add`], including [`Error::NoteAlreadyExists`](crate::error::Error::NoteAlreadyExists)
230    /// when `force` is false and a note already exists.
231    pub fn plan_add(
232        &self,
233        notes_ref: &NotesRef,
234        object: &ObjectId,
235        content: &[u8],
236        options: &NotesWriteOptions,
237        force: bool,
238    ) -> Result<NoteAddPlan> {
239        let (NotesSnapshot { tip, mut entries }, outcome) =
240            self.read_for_add(notes_ref, object, force)?;
241        let _overlay = MemOverlayGuard::enter(self.repo.odb());
242        let blob = self.repo.odb().write(ObjectKind::Blob, content)?;
243        upsert_note_entry(&mut entries, &object.to_hex(), blob);
244        let notes_ref_tip_after = self.preview_commit(tip, &entries, options)?;
245        Ok(NoteAddPlan {
246            object: *object,
247            outcome,
248            notes_ref_tip_before: tip,
249            notes_ref_tip_after,
250            note_blob: blob,
251        })
252    }
253
254    /// Preview [`Self::remove`] on `object` without persisting blobs, trees, commits, or refs.
255    ///
256    /// # Errors
257    ///
258    /// Same as [`Self::plan_remove_many`].
259    pub fn plan_remove(
260        &self,
261        notes_ref: &NotesRef,
262        object: &ObjectId,
263        options: &NotesWriteOptions,
264    ) -> Result<NoteRemovePlan> {
265        let plan = self.plan_remove_many(notes_ref, std::slice::from_ref(object), options)?;
266        Ok(NoteRemovePlan {
267            object: *object,
268            had_note: !plan.removed.is_empty(),
269            notes_ref_tip_before: plan.notes_ref_tip_before,
270            notes_ref_tip_after: plan.notes_ref_tip_after,
271        })
272    }
273
274    /// Preview [`Self::remove_many`] without persisting trees, commits, or refs.
275    ///
276    /// All removals are applied to one notes tree, so the predicted tip is the single notes
277    /// commit `remove_many` would write. Object writes are confined to the ODB in-memory
278    /// overlay and discarded before return.
279    ///
280    /// # Errors
281    ///
282    /// Propagates notes tree read failures and invalid identities in `options`.
283    pub fn plan_remove_many(
284        &self,
285        notes_ref: &NotesRef,
286        objects: &[ObjectId],
287        options: &NotesWriteOptions,
288    ) -> Result<NoteRemoveManyPlan> {
289        let StagedRemoval {
290            tip,
291            entries,
292            removed,
293        } = self.stage_removals(notes_ref, objects)?;
294        let notes_ref_tip_after = if removed.is_empty() {
295            None
296        } else {
297            let _overlay = MemOverlayGuard::enter(self.repo.odb());
298            Some(self.preview_commit(tip, &entries, options)?)
299        };
300        Ok(NoteRemoveManyPlan {
301            removed,
302            notes_ref_tip_before: tip,
303            notes_ref_tip_after,
304        })
305    }
306
307    /// Add or replace the note on `object`; `content` is stored exactly as given.
308    ///
309    /// When `force` is false and a note already exists, returns
310    /// [`Error::NoteAlreadyExists`](crate::error::Error::NoteAlreadyExists).
311    ///
312    /// # Returns
313    ///
314    /// Whether the note was newly [`Added`](NoteAddOutcome::Added) or
315    /// [`Replaced`](NoteAddOutcome::Replaced).
316    ///
317    /// # Errors
318    ///
319    /// Propagates notes tree read errors (invalid existing notes ref), blob write,
320    /// and notes commit failures. The notes ref is updated only if it still points at the tip
321    /// the existing notes were read from, so a concurrent notes update fails this call instead
322    /// of being dropped.
323    ///
324    /// # Examples
325    ///
326    /// ```
327    /// # use grit_lib::notes::NotesRef;
328    /// # use grit_lib::objects::ObjectId;
329    /// # use grit_lib::repo::Repository;
330    /// # use grit_lib::repo_handles::NotesWriteOptions;
331    /// # fn demo() -> grit_lib::error::Result<()> {
332    /// # let repo = Repository::discover(None, &grit_lib::config::environment::RepositoryOptions::empty())?;
333    /// # let object = ObjectId::zero();
334    /// let opts = NotesWriteOptions {
335    ///     message: "add note".into(),
336    ///     committer: "User <u@example.com> 1000000000 +0000".parse()?,
337    ///     author: None,
338    /// };
339    /// repo.notes().add(&NotesRef::commits(), &object, b"text\n", &opts, false)?;
340    /// # Ok(())
341    /// # }
342    /// ```
343    pub fn add(
344        &self,
345        notes_ref: &NotesRef,
346        object: &ObjectId,
347        content: &[u8],
348        options: &NotesWriteOptions,
349        force: bool,
350    ) -> Result<NoteAddOutcome> {
351        let (NotesSnapshot { tip, mut entries }, outcome) =
352            self.read_for_add(notes_ref, object, force)?;
353        let blob = self.repo.odb().write(ObjectKind::Blob, content)?;
354        upsert_note_entry(&mut entries, &object.to_hex(), blob);
355        self.write_commit(notes_ref, tip, &entries, options)?;
356        Ok(outcome)
357    }
358
359    /// Remove the note on `object` when present.
360    ///
361    /// # Errors
362    ///
363    /// Propagates notes commit failures when a note existed.
364    pub fn remove(
365        &self,
366        notes_ref: &NotesRef,
367        object: &ObjectId,
368        options: &NotesWriteOptions,
369    ) -> Result<()> {
370        self.remove_many(notes_ref, std::slice::from_ref(object), options)?;
371        Ok(())
372    }
373
374    /// Remove notes on each object in `objects`, writing at most one notes commit.
375    ///
376    /// Returns the object ids whose notes were removed.
377    ///
378    /// # Errors
379    ///
380    /// Propagates notes tree read and commit failures, including the compare-and-swap failure
381    /// described on [`Self::add`] when the notes ref moves concurrently.
382    pub fn remove_many(
383        &self,
384        notes_ref: &NotesRef,
385        objects: &[ObjectId],
386        options: &NotesWriteOptions,
387    ) -> Result<Vec<ObjectId>> {
388        let StagedRemoval {
389            tip,
390            entries,
391            removed,
392        } = self.stage_removals(notes_ref, objects)?;
393        if !removed.is_empty() {
394            self.write_commit(notes_ref, tip, &entries, options)?;
395        }
396        Ok(removed)
397    }
398
399    /// Read `notes_ref` and classify an add on `object`, refusing to replace without `force`.
400    fn read_for_add(
401        &self,
402        notes_ref: &NotesRef,
403        object: &ObjectId,
404        force: bool,
405    ) -> Result<(NotesSnapshot, NoteAddOutcome)> {
406        let snapshot = read_notes_snapshot(self.repo, notes_ref.as_str())?;
407        let hex = object.to_hex();
408        let exists = snapshot
409            .entries
410            .iter()
411            .any(|e| note_object_name(&e.path).as_deref() == Some(hex.as_str()));
412        if exists && !force {
413            return Err(crate::error::Error::NoteAlreadyExists { object: *object });
414        }
415        let outcome = if exists {
416            NoteAddOutcome::Replaced
417        } else {
418            NoteAddOutcome::Added
419        };
420        Ok((snapshot, outcome))
421    }
422
423    /// Read `notes_ref` and drop the note of each object in `objects` from the in-memory tree.
424    fn stage_removals(&self, notes_ref: &NotesRef, objects: &[ObjectId]) -> Result<StagedRemoval> {
425        let NotesSnapshot { tip, mut entries } =
426            read_notes_snapshot(self.repo, notes_ref.as_str())?;
427        let removed = objects
428            .iter()
429            .filter(|object| remove_note_entry(&mut entries, &object.to_hex()))
430            .copied()
431            .collect();
432        Ok(StagedRemoval {
433            tip,
434            entries,
435            removed,
436        })
437    }
438
439    /// Object id of the notes commit [`Self::write_commit`] would create; callers confine the
440    /// object writes with a [`MemOverlayGuard`].
441    fn preview_commit(
442        &self,
443        tip: Option<ObjectId>,
444        entries: &[NotesTreeEntry],
445        options: &NotesWriteOptions,
446    ) -> Result<ObjectId> {
447        let (author, committer) = options.header_lines()?;
448        let commit = NotesCommit {
449            message: &options.message,
450            author: &author,
451            committer: &committer,
452        };
453        preview_notes_commit(self.repo, tip, entries, &commit)
454    }
455
456    /// Write `entries` as a notes commit on `tip` and move `notes_ref` from `tip` to it.
457    fn write_commit(
458        &self,
459        notes_ref: &NotesRef,
460        tip: Option<ObjectId>,
461        entries: &[NotesTreeEntry],
462        options: &NotesWriteOptions,
463    ) -> Result<()> {
464        let (author, committer) = options.header_lines()?;
465        write_notes_commit_idents(
466            self.repo,
467            notes_ref.as_str(),
468            tip,
469            entries,
470            &options.message,
471            &author,
472            &committer,
473        )
474    }
475}