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/// What [`RepositoryNotes::add`] did to the annotated object's note.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum NoteAddOutcome {
89    /// The object had no note; one was attached.
90    Added,
91    /// The object already had a note and `force` replaced it.
92    Replaced,
93}
94
95/// Note entries of a notes tree whose path names an object (other entries are skipped).
96fn listings(entries: Vec<NotesTreeEntry>) -> impl Iterator<Item = NoteListing> {
97    entries.into_iter().filter_map(|e| {
98        let object = note_object_name(&e.path)?.parse().ok()?;
99        Some(NoteListing {
100            object,
101            note_blob: e.oid,
102        })
103    })
104}
105
106/// Notes read/write for one open repository (default ref: [`NotesRef::commits`]).
107#[derive(Debug, Copy, Clone)]
108pub struct RepositoryNotes<'repo> {
109    repo: &'repo Repository,
110}
111
112impl<'repo> RepositoryNotes<'repo> {
113    pub(crate) fn new(repo: &'repo Repository) -> Self {
114        Self { repo }
115    }
116
117    /// Borrow the underlying repository.
118    #[must_use]
119    pub fn repository(&self) -> &'repo Repository {
120        self.repo
121    }
122
123    /// List notes under `notes_ref`.
124    ///
125    /// # Errors
126    ///
127    /// Propagates notes tree read failures.
128    ///
129    /// # Examples
130    ///
131    /// ```
132    /// # use grit_lib::notes::NotesRef;
133    /// # use grit_lib::repo::Repository;
134    /// # use std::path::Path;
135    /// # fn demo() -> grit_lib::error::Result<()> {
136    /// let repo = Repository::discover(Some(Path::new(".")), &grit_lib::config::environment::RepositoryOptions::empty())?;
137    /// let notes = repo.notes().list(&NotesRef::commits(), None)?;
138    /// let _ = notes.items.len();
139    /// # Ok(())
140    /// # }
141    /// ```
142    pub fn list(&self, notes_ref: &NotesRef, limit: Option<usize>) -> Result<Limited<NoteListing>> {
143        let cap = fetch_plus_one(limit);
144        let (entries, stopped_early) = read_notes_tree_limited(self.repo, notes_ref.as_str(), cap)?;
145        let notes: Vec<NoteListing> = listings(entries).collect();
146        let mut limited = Limited::from_vec(notes, limit);
147        if stopped_early {
148            limited.truncated = true;
149        }
150        Ok(limited)
151    }
152
153    /// List notes under `notes_ref` together with their contents, sorted by annotated object.
154    ///
155    /// # Errors
156    ///
157    /// - Notes tree read failures (see [`Self::list`]).
158    /// - [`Error::NotesObjectKind`](crate::error::Error::NotesObjectKind) when a note entry is
159    ///   not a blob, and object read failures (a missing note blob is an error, not an empty
160    ///   note).
161    pub fn list_with_bodies(
162        &self,
163        notes_ref: &NotesRef,
164        limit: Option<usize>,
165    ) -> Result<Limited<Note>> {
166        let listed = self.list(notes_ref, limit)?;
167        let mut notes = Vec::with_capacity(listed.items.len());
168        for listing in listed.items {
169            notes.push(Note {
170                body: read_blob_bytes(self.repo, &listing.note_blob)?,
171                object: listing.object,
172                note_blob: listing.note_blob,
173            });
174        }
175        notes.sort_by_key(|note| note.object);
176        Ok(Limited {
177            items: notes,
178            truncated: listed.truncated,
179        })
180    }
181
182    /// Read note bytes for `object`, if present.
183    ///
184    /// # Errors
185    ///
186    /// Propagates notes tree and object read failures;
187    /// [`Error::NotesObjectKind`](crate::error::Error::NotesObjectKind) when the note entry is
188    /// not a blob.
189    pub fn read(&self, notes_ref: &NotesRef, object: &ObjectId) -> Result<Option<Vec<u8>>> {
190        let hex = object.to_hex();
191        let entries = read_notes_tree(self.repo, notes_ref.as_str())?;
192        let Some(entry) = entries
193            .iter()
194            .find(|e| note_object_name(&e.path).as_deref() == Some(hex.as_str()))
195        else {
196            return Ok(None);
197        };
198        read_blob_bytes(self.repo, &entry.oid).map(Some)
199    }
200
201    /// Preview [`Self::add`] without persisting blobs, trees, commits, or refs.
202    ///
203    /// Object writes are confined to the ODB in-memory overlay and discarded before return.
204    ///
205    /// # Errors
206    ///
207    /// Same as [`Self::add`], including [`Error::NoteAlreadyExists`](crate::error::Error::NoteAlreadyExists)
208    /// when `force` is false and a note already exists.
209    pub fn plan_add(
210        &self,
211        notes_ref: &NotesRef,
212        object: &ObjectId,
213        content: &[u8],
214        options: &NotesWriteOptions,
215        force: bool,
216    ) -> Result<NoteAddPlan> {
217        let hex = object.to_hex();
218        let NotesSnapshot { tip, mut entries } =
219            read_notes_snapshot(self.repo, notes_ref.as_str())?;
220        let exists = entries
221            .iter()
222            .any(|e| note_object_name(&e.path).as_deref() == Some(hex.as_str()));
223        if exists && !force {
224            return Err(crate::error::Error::NoteAlreadyExists { object: *object });
225        }
226        let outcome = if exists {
227            NoteAddOutcome::Replaced
228        } else {
229            NoteAddOutcome::Added
230        };
231        let _overlay = MemOverlayGuard::enter(self.repo.odb());
232        let blob = self.repo.odb().write(ObjectKind::Blob, content)?;
233        upsert_note_entry(&mut entries, &hex, blob);
234        let (author, committer) = options.header_lines()?;
235        let commit = NotesCommit {
236            message: &options.message,
237            author: &author,
238            committer: &committer,
239        };
240        let notes_ref_tip_after = preview_notes_commit(self.repo, tip, &entries, &commit)?;
241        Ok(NoteAddPlan {
242            object: *object,
243            outcome,
244            notes_ref_tip_before: tip,
245            notes_ref_tip_after,
246            note_blob: blob,
247        })
248    }
249
250    /// Preview [`Self::remove`] on `object` without persisting blobs, trees, commits, or refs.
251    ///
252    /// # Errors
253    ///
254    /// Propagates notes tree read failures.
255    pub fn plan_remove(
256        &self,
257        notes_ref: &NotesRef,
258        object: &ObjectId,
259        options: &NotesWriteOptions,
260    ) -> Result<NoteRemovePlan> {
261        let NotesSnapshot { tip, mut entries } =
262            read_notes_snapshot(self.repo, notes_ref.as_str())?;
263        let had_note = remove_note_entry(&mut entries, &object.to_hex());
264        if !had_note {
265            return Ok(NoteRemovePlan {
266                object: *object,
267                had_note: false,
268                notes_ref_tip_before: tip,
269                notes_ref_tip_after: None,
270            });
271        }
272        let _overlay = MemOverlayGuard::enter(self.repo.odb());
273        let (author, committer) = options.header_lines()?;
274        let commit = NotesCommit {
275            message: &options.message,
276            author: &author,
277            committer: &committer,
278        };
279        let notes_ref_tip_after = preview_notes_commit(self.repo, tip, &entries, &commit)?;
280        Ok(NoteRemovePlan {
281            object: *object,
282            had_note: true,
283            notes_ref_tip_before: tip,
284            notes_ref_tip_after: Some(notes_ref_tip_after),
285        })
286    }
287
288    /// Add or replace the note on `object`; `content` is stored exactly as given.
289    ///
290    /// When `force` is false and a note already exists, returns
291    /// [`Error::NoteAlreadyExists`](crate::error::Error::NoteAlreadyExists).
292    ///
293    /// # Returns
294    ///
295    /// Whether the note was newly [`Added`](NoteAddOutcome::Added) or
296    /// [`Replaced`](NoteAddOutcome::Replaced).
297    ///
298    /// # Errors
299    ///
300    /// Propagates notes tree read errors (invalid existing notes ref), blob write,
301    /// and notes commit failures. The notes ref is updated only if it still points at the tip
302    /// the existing notes were read from, so a concurrent notes update fails this call instead
303    /// of being dropped.
304    ///
305    /// # Examples
306    ///
307    /// ```
308    /// # use grit_lib::notes::NotesRef;
309    /// # use grit_lib::objects::ObjectId;
310    /// # use grit_lib::repo::Repository;
311    /// # use grit_lib::repo_handles::NotesWriteOptions;
312    /// # fn demo() -> grit_lib::error::Result<()> {
313    /// # let repo = Repository::discover(None, &grit_lib::config::environment::RepositoryOptions::empty())?;
314    /// # let object = ObjectId::zero();
315    /// let opts = NotesWriteOptions {
316    ///     message: "add note".into(),
317    ///     committer: "User <u@example.com> 1000000000 +0000".parse()?,
318    ///     author: None,
319    /// };
320    /// repo.notes().add(&NotesRef::commits(), &object, b"text\n", &opts, false)?;
321    /// # Ok(())
322    /// # }
323    /// ```
324    pub fn add(
325        &self,
326        notes_ref: &NotesRef,
327        object: &ObjectId,
328        content: &[u8],
329        options: &NotesWriteOptions,
330        force: bool,
331    ) -> Result<NoteAddOutcome> {
332        let hex = object.to_hex();
333        let NotesSnapshot { tip, mut entries } =
334            read_notes_snapshot(self.repo, notes_ref.as_str())?;
335        let exists = entries
336            .iter()
337            .any(|e| note_object_name(&e.path).as_deref() == Some(hex.as_str()));
338        if exists && !force {
339            return Err(crate::error::Error::NoteAlreadyExists { object: *object });
340        }
341        let blob = self.repo.odb().write(ObjectKind::Blob, content)?;
342        upsert_note_entry(&mut entries, &hex, blob);
343        let (author, committer) = options.header_lines()?;
344        write_notes_commit_idents(
345            self.repo,
346            notes_ref.as_str(),
347            tip,
348            &entries,
349            &options.message,
350            &author,
351            &committer,
352        )?;
353        Ok(if exists {
354            NoteAddOutcome::Replaced
355        } else {
356            NoteAddOutcome::Added
357        })
358    }
359
360    /// Remove the note on `object` when present.
361    ///
362    /// # Errors
363    ///
364    /// Propagates notes commit failures when a note existed.
365    pub fn remove(
366        &self,
367        notes_ref: &NotesRef,
368        object: &ObjectId,
369        options: &NotesWriteOptions,
370    ) -> Result<()> {
371        self.remove_many(notes_ref, std::slice::from_ref(object), options)?;
372        Ok(())
373    }
374
375    /// Remove notes on each object in `objects`, writing at most one notes commit.
376    ///
377    /// Returns the object ids whose notes were removed.
378    ///
379    /// # Errors
380    ///
381    /// Propagates notes tree read and commit failures, including the compare-and-swap failure
382    /// described on [`Self::add`] when the notes ref moves concurrently.
383    pub fn remove_many(
384        &self,
385        notes_ref: &NotesRef,
386        objects: &[ObjectId],
387        options: &NotesWriteOptions,
388    ) -> Result<Vec<ObjectId>> {
389        let NotesSnapshot { tip, mut entries } =
390            read_notes_snapshot(self.repo, notes_ref.as_str())?;
391        let mut removed = Vec::new();
392        for object in objects {
393            if remove_note_entry(&mut entries, &object.to_hex()) {
394                removed.push(*object);
395            }
396        }
397        if removed.is_empty() {
398            return Ok(removed);
399        }
400        let (author, committer) = options.header_lines()?;
401        write_notes_commit_idents(
402            self.repo,
403            notes_ref.as_str(),
404            tip,
405            &entries,
406            &options.message,
407            &author,
408            &committer,
409        )?;
410        Ok(removed)
411    }
412}