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}