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}