Skip to main content

max / makenotwork

server: git notes read core P0 of the notes program (wiki mnw-server-git-notes). Read-only: namespaces, lookup, batch lookup, attribution, and a cache. No write path, no schema, no routes -- P1 renders this, P2 writes. refs/notes/<ns> is a commit whose tree maps a target object id to a content blob, and the fanout is variable: ab/cdef, ab/cd/ef, or the flat 40-hex name, because git rebalances the tree as it grows. A reader that assumes 2/38 is wrong on any repository git itself has touched, so the descent checks both the remaining hex as a leaf and the next two characters as a subtree at every level, bounded to 32. The fixtures build a tree holding all three shapes at once, which is what a growing repository actually looks like mid-rebalance, and going through `git notes` to produce them would let git rebalance the shapes away. notes_for_many branches on target count: three or more flattens the tree once, one or two descends per target. A test asserts the two paths agree, since a divergence would surface as the log page and the commit page disagreeing about whether a commit is annotated, which reads as flakiness rather than as a bug. attribution carries an exact flag. The tree holds content, not history, so naming the commit that set a note means walking the notes ref comparing each commit's value against its first parent's, and that walk is bounded because it runs on a detail view. When the budget runs out it returns the oldest commit examined and says so rather than crediting an author it has not proven. Structured for extraction. Notes are a convention over trees, not a storage format, and the semantics worth having -- fanout, the merge strategies P3 needs, attribution -- are worth having independently of the engine underneath. gix has no notes API at all; libgit2 has primitives without merge strategies. So engine.rs defines the boundary and names no gix type, gix_engine.rs is the one implementation, and mod.rs is generic over the trait. Reads take a visitor or a caller-owned buffer rather than returning owned collections: a callback boundary would otherwise forfeit gix's allocation-free tree iteration on exactly the hot path, and widening those signatures later would break the API. Two rules keep the trait implementable over libgit2 or a git subprocess: the layer above never computes an object id, and ref updates are single-ref CAS only. The cache is a type callers hold, not a global, since the layer below it is meant to be liftable into a library. Keyed on (repo, namespace, ref tip), so a stale entry is not possible, only a missed one -- when the ref moves the key moves with it and there is no invalidation to get wrong. The repo component is load-bearing: a fork shares its notes tree, so the tip alone would collide across repositories. 18 tests; clippy and fmt clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 18:40 UTC
Signed with PGP, not checked
Commit: ccd53d54364e9e0e9d51033ed492c48cb5c5631f
Parent: 64233e8
5 files changed, +1325 insertions, -0 deletions
@@ -4,6 +4,7 @@
4 4 //! Repository is opened per-request (cheap, just file descriptors).
5 5
6 6 mod history;
7 + pub mod notes;
7 8 mod objects;
8 9 mod refs;
9 10
@@ -1,0 +1,266 @@
1 + //! The engine boundary: the object-store operations the notes layer needs, and
2 + //! the types it speaks in.
3 + //!
4 + //! Nothing in this file mentions gitoxide, and that is the whole point. Notes
5 + //! are a convention over trees, not a storage format, so the semantics above
6 + //! this line (fanout, merge strategies, attribution) are worth having
7 + //! independently of whichever engine happens to be underneath. gix has no notes
8 + //! API at all; libgit2 has primitives without merge strategies. This module is
9 + //! shaped so it can be lifted out into a standalone crate once P3's merge
10 + //! strategies exist. See wiki `mnw-server-git-notes`, "Extraction".
11 + //!
12 + //! Two rules keep the trait implementable over gix, libgit2, or a `git`
13 + //! subprocess alike:
14 + //!
15 + //! 1. **The notes layer never computes an object ID.** It hands bytes to the
16 + //! engine and receives an [`Oid`] back. That keeps it out of the hashing
17 + //! business, and makes SHA-256 repositories a matter of the engine rather
18 + //! than a port.
19 + //! 2. **Single-ref compare-and-swap only** (arriving with the write half in
20 + //! P2). Multi-ref atomicity is engine-specific; promising it would make the
21 + //! trait unimplementable over a subprocess.
22 + //!
23 + //! Reads take a visitor or a caller-owned buffer rather than returning owned
24 + //! collections. A per-object callback boundary otherwise forfeits gix's
25 + //! allocation-free tree iteration on exactly the hot path — `notes_for_many`
26 + //! over a log page — and widening the signatures later would break the API.
27 +
28 + use std::fmt;
29 +
30 + /// Largest object ID the engine boundary carries, in bytes: SHA-256.
31 + const MAX_OID_BYTES: usize = 32;
32 +
33 + /// An object ID, hash-agnostic.
34 + ///
35 + /// Fixed-size and `Copy` so tree walks and lookup maps never allocate per
36 + /// entry. Only the first `len` bytes are meaningful; SHA-1 is 20, SHA-256 is 32.
37 + #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
38 + pub struct Oid {
39 + len: u8,
40 + bytes: [u8; MAX_OID_BYTES],
41 + }
42 +
43 + impl Oid {
44 + /// Build from raw hash bytes. Accepts SHA-1 (20) and SHA-256 (32) widths.
45 + pub fn from_bytes(raw: &[u8]) -> Result<Self, NotesError> {
46 + if raw.len() != 20 && raw.len() != MAX_OID_BYTES {
47 + return Err(NotesError::Malformed(format!(
48 + "object id is {} bytes, expected 20 or 32",
49 + raw.len()
50 + )));
51 + }
52 + let mut bytes = [0u8; MAX_OID_BYTES];
53 + bytes[..raw.len()].copy_from_slice(raw);
54 + Ok(Self {
55 + len: raw.len() as u8,
56 + bytes,
57 + })
58 + }
59 +
60 + /// Parse lowercase or uppercase hex. The length decides the hash width, so
61 + /// a truncated prefix is rejected rather than silently zero-padded — note
62 + /// tree paths are always full IDs, and a short one means a malformed tree.
63 + pub fn from_hex(hex_bytes: &[u8]) -> Result<Self, NotesError> {
64 + if hex_bytes.len() != 40 && hex_bytes.len() != 64 {
65 + return Err(NotesError::Malformed(format!(
66 + "object id is {} hex chars, expected 40 or 64",
67 + hex_bytes.len()
68 + )));
69 + }
70 + let mut bytes = [0u8; MAX_OID_BYTES];
71 + let len = hex_bytes.len() / 2;
72 + hex::decode_to_slice(hex_bytes, &mut bytes[..len])
73 + .map_err(|e| NotesError::Malformed(format!("object id is not hex: {e}")))?;
74 + Ok(Self {
75 + len: len as u8,
76 + bytes,
77 + })
78 + }
79 +
80 + /// The raw hash bytes.
81 + pub fn as_bytes(&self) -> &[u8] {
82 + &self.bytes[..self.len as usize]
83 + }
84 +
85 + /// Length of this ID's hex form, in characters.
86 + pub fn hex_len(&self) -> usize {
87 + self.len as usize * 2
88 + }
89 +
90 + /// Lowercase hex.
91 + pub fn to_hex(self) -> String {
92 + hex::encode(self.as_bytes())
93 + }
94 +
95 + /// First `n` characters of the hex form, for display.
96 + pub fn to_short_hex(self, n: usize) -> String {
97 + let mut s = self.to_hex();
98 + s.truncate(n.min(self.hex_len()));
99 + s
100 + }
101 + }
102 +
103 + impl fmt::Display for Oid {
104 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 + f.write_str(&self.to_hex())
106 + }
107 + }
108 +
109 + impl fmt::Debug for Oid {
110 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 + write!(f, "Oid({})", self.to_hex())
112 + }
113 + }
114 +
115 + /// What a tree entry points at. Anything that is neither a subtree nor a blob
116 + /// (commit entries for submodules, in practice) is `Other` — the notes layer
117 + /// only ever descends into trees and reads blobs.
118 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
119 + pub enum EntryKind {
120 + Tree,
121 + Blob,
122 + Other,
123 + }
124 +
125 + /// One entry in a tree, borrowed for the duration of the visit.
126 + #[derive(Debug, Clone, Copy)]
127 + pub struct TreeEntry<'a> {
128 + /// Raw filename bytes. Note trees hold hex path segments, but a tree can
129 + /// hold any bytes, so this is not assumed to be UTF-8.
130 + pub name: &'a [u8],
131 + pub kind: EntryKind,
132 + pub oid: Oid,
133 + }
134 +
135 + /// Whether a tree walk continues after the current entry. Lookups stop as soon
136 + /// as they have their answer rather than reading the rest of a fanout level.
137 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
138 + pub enum Walk {
139 + Continue,
140 + Stop,
141 + }
142 +
143 + /// Who did something, and when.
144 + #[derive(Debug, Clone, PartialEq, Eq)]
145 + pub struct Signature {
146 + pub name: String,
147 + pub email: String,
148 + pub time: chrono::DateTime<chrono::Utc>,
149 + }
150 +
151 + /// The parts of a commit the notes layer reads. Notes commits carry no diff of
152 + /// interest and their messages are conventional, so this is deliberately less
153 + /// than a full commit.
154 + #[derive(Debug, Clone)]
155 + pub struct CommitMeta {
156 + pub tree: Oid,
157 + pub parents: Vec<Oid>,
158 + pub author: Signature,
159 + pub committer: Signature,
160 + pub message: String,
161 + }
162 +
163 + /// Failures at or above the engine boundary.
164 + ///
165 + /// `Backend` carries a rendered string rather than a boxed engine error: the
166 + /// point of the boundary is that callers above it cannot name gix types, and
167 + /// that includes gix error types.
168 + #[derive(Debug, thiserror::Error)]
169 + pub enum NotesError {
170 + /// The object or ref exists in the request but not in the repository.
171 + #[error("not found")]
172 + NotFound,
173 + /// The repository holds something the notes convention does not allow.
174 + #[error("malformed notes data: {0}")]
175 + Malformed(String),
176 + /// The engine failed.
177 + #[error("git backend error: {0}")]
178 + Backend(String),
179 + }
180 +
181 + impl From<NotesError> for super::super::GitError {
182 + fn from(e: NotesError) -> Self {
183 + match e {
184 + NotesError::NotFound => Self::RefNotFound,
185 + NotesError::Malformed(m) => Self::Git(format!("malformed notes data: {m}")),
186 + NotesError::Backend(m) => Self::Git(m),
187 + }
188 + }
189 + }
190 +
191 + /// The read half of the engine boundary.
192 + ///
193 + /// The write half (`write_blob`, `write_tree`, `write_commit`,
194 + /// `update_ref_cas`) lands with P2 as a separate trait, so that the browsing
195 + /// path — which is read-only — never depends on a repository being writable.
196 + pub trait NoteObjects {
197 + /// Resolve a full ref name (`refs/notes/commits`) to the commit it points
198 + /// at, following annotated tags. `Ok(None)` means the ref does not exist,
199 + /// which for notes is the ordinary case, not an error.
200 + fn resolve_ref(&self, full_name: &str) -> Result<Option<Oid>, NotesError>;
201 +
202 + /// Visit every ref under `prefix`, passing the full ref name and its
203 + /// resolved commit. Refs that fail to resolve are skipped, not reported: a
204 + /// broken ref elsewhere in the repository must not fail a notes listing.
205 + fn list_refs(&self, prefix: &str, visit: &mut dyn FnMut(&str, Oid)) -> Result<(), NotesError>;
206 +
207 + fn read_commit(&self, oid: Oid) -> Result<CommitMeta, NotesError>;
208 +
209 + /// Visit the entries of a tree in the order the engine yields them, which
210 + /// for git is name order.
211 + fn read_tree_with(
212 + &self,
213 + oid: Oid,
214 + visit: &mut dyn FnMut(TreeEntry<'_>) -> Walk,
215 + ) -> Result<(), NotesError>;
216 +
217 + /// Append a blob's bytes to `out`. The caller owns the buffer so a batch
218 + /// read can reuse one allocation.
219 + fn read_blob_into(&self, oid: Oid, out: &mut Vec<u8>) -> Result<(), NotesError>;
220 + }
221 +
222 + #[cfg(test)]
223 + mod tests {
224 + use super::*;
225 +
226 + #[test]
227 + fn oid_hex_roundtrip() {
228 + let hex = "0123456789abcdef0123456789abcdef01234567";
229 + let oid = Oid::from_hex(hex.as_bytes()).unwrap();
230 + assert_eq!(oid.to_hex(), hex);
231 + assert_eq!(oid.hex_len(), 40);
232 + assert_eq!(oid.as_bytes().len(), 20);
233 + }
234 +
235 + #[test]
236 + fn oid_accepts_sha256_width() {
237 + let hex = "0123456789abcdef".repeat(4);
238 + let oid = Oid::from_hex(hex.as_bytes()).unwrap();
239 + assert_eq!(oid.hex_len(), 64);
240 + assert_eq!(oid.to_hex(), hex);
241 + }
242 +
243 + #[test]
244 + fn oid_rejects_partial_and_nonhex() {
245 + // A prefix is not an object ID. Note tree paths always spell the whole
246 + // thing, so accepting a short one would mean accepting a broken tree.
247 + assert!(Oid::from_hex(b"0123456789abcdef").is_err());
248 + assert!(Oid::from_hex("z".repeat(40).as_bytes()).is_err());
249 + assert!(Oid::from_bytes(&[0u8; 21]).is_err());
250 + }
251 +
252 + #[test]
253 + fn oid_bytes_roundtrip() {
254 + let raw = [7u8; 20];
255 + let oid = Oid::from_bytes(&raw).unwrap();
256 + assert_eq!(oid.as_bytes(), raw);
257 + assert_eq!(Oid::from_hex(oid.to_hex().as_bytes()).unwrap(), oid);
258 + }
259 +
260 + #[test]
261 + fn oid_short_hex_clamps() {
262 + let oid = Oid::from_bytes(&[0xab; 20]).unwrap();
263 + assert_eq!(oid.to_short_hex(4), "abab");
264 + assert_eq!(oid.to_short_hex(200).len(), 40);
265 + }
266 + }
@@ -1,0 +1,172 @@
1 + //! The one implementation of [`NoteObjects`], over gitoxide.
2 + //!
3 + //! Everything gix-specific in the notes subsystem lives in this file. If a
4 + //! second engine ever appears (or this layer is lifted into its own crate),
5 + //! this is the file that gets a sibling and nothing above it changes.
6 +
7 + use gix::bstr::ByteSlice;
8 +
9 + use super::engine::{
10 + CommitMeta, EntryKind, NoteObjects, NotesError, Oid, Signature, TreeEntry, Walk,
11 + };
12 +
13 + /// Adapts a borrowed [`gix::Repository`] to the notes engine boundary.
14 + ///
15 + /// Borrowed rather than owned because the repository handle is `!Send` and
16 + /// callers already hold one inside `ResolvedRepo::with_repo`'s blocking
17 + /// closure; taking ownership here would just move the constraint around.
18 + pub struct GixEngine<'repo> {
19 + repo: &'repo gix::Repository,
20 + }
21 +
22 + impl<'repo> GixEngine<'repo> {
23 + pub fn new(repo: &'repo gix::Repository) -> Self {
24 + Self { repo }
25 + }
26 + }
27 +
28 + /// gix object ID to ours.
29 + fn to_oid(id: &gix::hash::oid) -> Result<Oid, NotesError> {
30 + Oid::from_bytes(id.as_bytes())
31 + }
32 +
33 + /// Ours to gix's.
34 + fn to_gix_oid(oid: Oid) -> Result<gix::ObjectId, NotesError> {
35 + gix::ObjectId::try_from(oid.as_bytes())
36 + .map_err(|e| NotesError::Malformed(format!("object id rejected by backend: {e}")))
37 + }
38 +
39 + /// gix signature to ours, tolerating the malformed dates git itself tolerates.
40 + ///
41 + /// A note whose author line is garbage still has content worth showing, so an
42 + /// undecodable timestamp becomes the epoch rather than an error — the same
43 + /// call the commit-log path already makes in `history.rs`.
44 + fn to_signature(sig: gix::actor::SignatureRef<'_>) -> Signature {
45 + let seconds = sig.time().map(|t| t.seconds).unwrap_or_default();
46 + Signature {
47 + name: sig.name.to_str_lossy().into_owned(),
48 + email: sig.email.to_str_lossy().into_owned(),
49 + time: chrono::DateTime::from_timestamp(seconds, 0).unwrap_or_default(),
50 + }
51 + }
52 +
53 + fn to_entry_kind(mode: gix::objs::tree::EntryMode) -> EntryKind {
54 + match mode.kind() {
55 + gix::objs::tree::EntryKind::Tree => EntryKind::Tree,
56 + gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable => {
57 + EntryKind::Blob
58 + }
59 + _ => EntryKind::Other,
60 + }
61 + }
62 +
63 + impl NoteObjects for GixEngine<'_> {
64 + fn resolve_ref(&self, full_name: &str) -> Result<Option<Oid>, NotesError> {
65 + let Ok(mut reference) = self.repo.find_reference(full_name) else {
66 + return Ok(None);
67 + };
68 + // A notes ref that exists but cannot be peeled is a broken repository,
69 + // not an absent namespace, so this one does surface as an error.
70 + let id = reference
71 + .peel_to_id()
72 + .map_err(|e| NotesError::Backend(format!("cannot peel {full_name}: {e}")))?;
73 + to_oid(&id).map(Some)
74 + }
75 +
76 + fn list_refs(&self, prefix: &str, visit: &mut dyn FnMut(&str, Oid)) -> Result<(), NotesError> {
77 + let platform = self
78 + .repo
79 + .references()
80 + .map_err(|e| NotesError::Backend(format!("cannot read refs: {e}")))?;
81 + let refs = platform
82 + .prefixed(prefix)
83 + .map_err(|e| NotesError::Backend(format!("cannot list {prefix}: {e}")))?;
84 +
85 + for reference in refs.flatten() {
86 + let mut reference = reference;
87 + let name = reference.name().as_bstr().to_str_lossy().into_owned();
88 + // Skip rather than fail: one unresolvable ref must not take out the
89 + // whole namespace listing.
90 + let Ok(id) = reference.peel_to_id() else {
91 + continue;
92 + };
93 + let Ok(oid) = to_oid(&id) else { continue };
94 + visit(&name, oid);
95 + }
96 + Ok(())
97 + }
98 +
99 + fn read_commit(&self, oid: Oid) -> Result<CommitMeta, NotesError> {
100 + let commit = self
101 + .repo
102 + .find_commit(to_gix_oid(oid)?)
103 + .map_err(|_| NotesError::NotFound)?;
104 +
105 + let tree = commit
106 + .tree_id()
107 + .map_err(|e| NotesError::Malformed(format!("commit {oid} has no tree: {e}")))?;
108 +
109 + let mut parents = Vec::new();
110 + for parent in commit.parent_ids() {
111 + parents.push(to_oid(&parent)?);
112 + }
113 +
114 + let author = commit
115 + .author()
116 + .map(to_signature)
117 + .map_err(|e| NotesError::Malformed(format!("commit {oid} author: {e}")))?;
118 + let committer = commit
119 + .committer()
120 + .map(to_signature)
121 + .map_err(|e| NotesError::Malformed(format!("commit {oid} committer: {e}")))?;
122 +
123 + Ok(CommitMeta {
124 + tree: to_oid(&tree)?,
125 + parents,
126 + author,
127 + committer,
128 + message: commit.message_raw_sloppy().to_str_lossy().into_owned(),
129 + })
130 + }
131 +
132 + fn read_tree_with(
133 + &self,
134 + oid: Oid,
135 + visit: &mut dyn FnMut(TreeEntry<'_>) -> Walk,
136 + ) -> Result<(), NotesError> {
137 + let tree = self
138 + .repo
139 + .find_tree(to_gix_oid(oid)?)
140 + .map_err(|_| NotesError::NotFound)?;
141 +
142 + for entry in tree.iter() {
143 + let entry =
144 + entry.map_err(|e| NotesError::Malformed(format!("tree {oid} entry: {e}")))?;
145 + let entry_oid = to_oid(entry.oid())?;
146 + let visited = visit(TreeEntry {
147 + name: entry.filename(),
148 + kind: to_entry_kind(entry.mode()),
149 + oid: entry_oid,
150 + });
151 + if visited == Walk::Stop {
152 + break;
153 + }
154 + }
155 + Ok(())
156 + }
157 +
158 + fn read_blob_into(&self, oid: Oid, out: &mut Vec<u8>) -> Result<(), NotesError> {
159 + let object = self
160 + .repo
161 + .find_object(to_gix_oid(oid)?)
162 + .map_err(|_| NotesError::NotFound)?;
163 + if object.kind != gix::object::Kind::Blob {
164 + return Err(NotesError::Malformed(format!(
165 + "expected {oid} to be a blob, found a {}",
166 + object.kind
167 + )));
168 + }
169 + out.extend_from_slice(&object.data);
170 + Ok(())
171 + }
172 + }
@@ -1,0 +1,456 @@
1 + //! Git notes: the read core.
2 + //!
3 + //! <!-- wiki: mnw-server-git-notes -->
4 + //!
5 + //! `refs/notes/<namespace>` is a commit whose tree maps a target object ID to a
6 + //! blob of note content. The path fanout is **variable** — `ab/cdef…`,
7 + //! `ab/cd/ef…`, or the flat 40-hex name — because git rebalances the tree as it
8 + //! grows, so a lookup that only tries `2/38` is wrong on any repository git
9 + //! itself has touched. Targets need not be commits: blobs, trees and tags can
10 + //! all be annotated, and this module makes no assumption either way.
11 + //!
12 + //! Everything here is generic over [`engine::NoteObjects`] and names no gix
13 + //! type; see `engine.rs` for why, and wiki `mnw-server-git-notes` for where
14 + //! this is going (a standalone crate, extracted once the P3 merge strategies
15 + //! exist and give it something no other implementation has).
16 + //!
17 + //! Read-only. The write path, its compare-and-swap ref update, and the merge
18 + //! strategies arrive in P2 and P3.
19 +
20 + pub mod engine;
21 + pub mod gix_engine;
22 +
23 + use std::collections::{HashMap, HashSet};
24 + use std::sync::Arc;
25 +
26 + use dashmap::DashMap;
27 +
28 + pub use engine::{CommitMeta, NoteObjects, NotesError, Oid, Signature};
29 + pub use gix_engine::GixEngine;
30 +
31 + use engine::{EntryKind, TreeEntry, Walk};
32 +
33 + /// Every notes ref lives under here.
34 + pub const NOTES_REF_PREFIX: &str = "refs/notes/";
35 +
36 + /// The namespace git writes to when none is named (`git notes add`).
37 + pub const DEFAULT_NAMESPACE: &str = "commits";
38 +
39 + /// A note tree path cannot split into more levels than it has byte pairs; 32
40 + /// covers SHA-256. The bound exists so a maliciously deep tree cannot drive the
41 + /// walk into unbounded work.
42 + const MAX_FANOUT_DEPTH: usize = 32;
43 +
44 + /// One `refs/notes/*` namespace.
45 + #[derive(Debug, Clone, PartialEq, Eq)]
46 + pub struct Namespace {
47 + /// Name as a person says it: `commits`, `mnw/builds`.
48 + pub name: String,
49 + /// Full ref name: `refs/notes/mnw/builds`.
50 + pub full_ref: String,
51 + /// Commit the ref currently points at. Doubles as the cache key — see
52 + /// [`NotesCache`].
53 + pub tip: Oid,
54 + }
55 +
56 + /// A note and the object it annotates.
57 + #[derive(Debug, Clone)]
58 + pub struct Note {
59 + pub target: Oid,
60 + pub blob: Oid,
61 + /// Raw bytes. Notes are conventionally UTF-8 text but nothing enforces it,
62 + /// and the decision of what to do about that belongs to the renderer.
63 + pub content: Vec<u8>,
64 + }
65 +
66 + impl Note {
67 + /// Content as text, replacing anything undecodable. What the commit page
68 + /// feeds to markdown rendering.
69 + pub fn content_lossy(&self) -> std::borrow::Cow<'_, str> {
70 + String::from_utf8_lossy(&self.content)
71 + }
72 + }
73 +
74 + /// Who last changed a note, and in which notes commit.
75 + #[derive(Debug, Clone)]
76 + pub struct Attribution {
77 + /// The commit on the notes ref that set the note to its current content.
78 + pub note_commit: Oid,
79 + pub blob: Oid,
80 + pub by: Signature,
81 + pub message: String,
82 + /// False when the history walk hit its budget before finding the change,
83 + /// making this the oldest commit examined rather than the one responsible.
84 + /// A caller rendering "edited by X" should soften it to "edited since" when
85 + /// this is false.
86 + pub exact: bool,
87 + }
88 +
89 + // ── Namespaces ──
90 +
91 + /// List every notes namespace, with the tip each points at.
92 + ///
93 + /// `commits` sorts first because it is git's default and the one a reader means
94 + /// when they say "the notes"; the rest are alphabetical.
95 + pub fn list_namespaces<E: NoteObjects>(engine: &E) -> Result<Vec<Namespace>, NotesError> {
96 + let mut out = Vec::new();
97 + engine.list_refs(NOTES_REF_PREFIX, &mut |full_ref, tip| {
98 + let Some(name) = full_ref.strip_prefix(NOTES_REF_PREFIX) else {
99 + return;
100 + };
101 + if name.is_empty() {
102 + return;
103 + }
104 + out.push(Namespace {
105 + name: name.to_string(),
106 + full_ref: full_ref.to_string(),
107 + tip,
108 + });
109 + })?;
110 +
111 + out.sort_by(|a, b| {
112 + let rank = |n: &str| usize::from(n != DEFAULT_NAMESPACE);
113 + rank(&a.name)
114 + .cmp(&rank(&b.name))
115 + .then_with(|| a.name.cmp(&b.name))
116 + });
117 + Ok(out)
118 + }
119 +
120 + /// Resolve one namespace by name. `Ok(None)` means it does not exist, which is
121 + /// the ordinary case for a repository nobody has annotated.
122 + pub fn resolve_namespace<E: NoteObjects>(
123 + engine: &E,
124 + name: &str,
125 + ) -> Result<Option<Namespace>, NotesError> {
126 + let full_ref = format!("{NOTES_REF_PREFIX}{name}");
127 + Ok(engine.resolve_ref(&full_ref)?.map(|tip| Namespace {
128 + name: name.to_string(),
129 + full_ref,
130 + tip,
131 + }))
132 + }
133 +
134 + // ── Lookup ──
135 +
136 + /// Read the note on `target` in the namespace whose tip is `tip`.
137 + pub fn note_for<E: NoteObjects>(
138 + engine: &E,
139 + tip: Oid,
140 + target: Oid,
141 + ) -> Result<Option<Note>, NotesError> {
142 + let tree = engine.read_commit(tip)?.tree;
143 + let Some(blob) = find_note_blob(engine, tree, target)? else {
144 + return Ok(None);
145 + };
146 + let mut content = Vec::new();
147 + engine.read_blob_into(blob, &mut content)?;
148 + Ok(Some(Note {
149 + target,
150 + blob,
151 + content,
152 + }))
153 + }
154 +
155 + /// Map the targets that carry a note to the blob holding it.
156 + ///
157 + /// Flattens the whole notes tree once rather than descending per target: the
158 + /// caller is a log page asking about every commit on it, where per-target
159 + /// descent would be one tree walk per row. For one or two targets the descent
160 + /// is cheaper and this takes that path instead.
161 + pub fn notes_for_many<E: NoteObjects>(
162 + engine: &E,
163 + tip: Oid,
164 + targets: &[Oid],
165 + ) -> Result<HashMap<Oid, Oid>, NotesError> {
166 + if targets.is_empty() {
167 + return Ok(HashMap::new());
168 + }
169 + let tree = engine.read_commit(tip)?.tree;
170 +
171 + if targets.len() <= 2 {
172 + let mut found = HashMap::new();
173 + for target in targets {
174 + if let Some(blob) = find_note_blob(engine, tree, *target)? {
175 + found.insert(*target, blob);
176 + }
177 + }
178 + return Ok(found);
179 + }
180 +
181 + let wanted: HashSet<Oid> = targets.iter().copied().collect();
182 + let mut found = HashMap::with_capacity(wanted.len());
183 + flatten(engine, tree, &mut |target, blob| {
184 + if wanted.contains(&target) {
185 + found.insert(target, blob);
186 + }
187 + })?;
188 + Ok(found)
189 + }
190 +
191 + /// Every annotated target in a namespace, with its note blob, sorted by target.
192 + ///
193 + /// O(size of the notes tree). The Notes tab wants exactly this; nothing on a
194 + /// per-commit path should call it.
195 + pub fn annotated_targets<E: NoteObjects>(
196 + engine: &E,
197 + tip: Oid,
198 + ) -> Result<Vec<(Oid, Oid)>, NotesError> {
199 + let tree = engine.read_commit(tip)?.tree;
200 + let mut out = Vec::new();
201 + flatten(engine, tree, &mut |target, blob| out.push((target, blob)))?;
202 + out.sort_unstable_by_key(|(target, _)| *target);
203 + Ok(out)
204 + }
205 +
206 + /// Count the notes in a namespace without materializing them.
207 + pub fn count_notes<E: NoteObjects>(engine: &E, tip: Oid) -> Result<usize, NotesError> {
208 + let tree = engine.read_commit(tip)?.tree;
209 + let mut count = 0usize;
210 + flatten(engine, tree, &mut |_, _| count += 1)?;
211 + Ok(count)
212 + }
213 +
214 + /// Descend the fanout to the blob annotating `target`, trying every split depth
215 + /// git may have produced.
216 + ///
217 + /// At each level a note tree may hold the remaining hex as a leaf, or the next
218 + /// two characters as a subtree. Both are checked, the leaf wins, and anything
219 + /// else at that level is ignored — note trees can carry unrelated paths (a
220 + /// leftover from a hand-rolled `notes merge`, say) and those are not an error.
221 + fn find_note_blob<E: NoteObjects>(
222 + engine: &E,
223 + root_tree: Oid,
224 + target: Oid,
225 + ) -> Result<Option<Oid>, NotesError> {
226 + let hex = target.to_hex();
227 + let mut remaining = hex.as_bytes();
228 + let mut tree = root_tree;
229 +
230 + for _ in 0..MAX_FANOUT_DEPTH {
231 + let mut leaf: Option<Oid> = None;
232 + let mut subtree: Option<Oid> = None;
233 +
234 + engine.read_tree_with(tree, &mut |entry: TreeEntry<'_>| {
235 + match entry.kind {
236 + EntryKind::Blob if entry.name == remaining => {
237 + leaf = Some(entry.oid);
238 + return Walk::Stop;
239 + }
240 + EntryKind::Tree if remaining.len() > 2 && entry.name == &remaining[..2] => {
241 + subtree = Some(entry.oid);
242 + }
243 + _ => {}
244 + }
245 + Walk::Continue
246 + })?;
247 +
248 + if let Some(blob) = leaf {
249 + return Ok(Some(blob));
250 + }
251 + let Some(next) = subtree else {
252 + return Ok(None);
253 + };
254 + tree = next;
255 + remaining = &remaining[2..];
256 + }
257 + // Deeper than any legitimate fanout: treat as absent rather than looping.
258 + Ok(None)
259 + }
260 +
261 + /// Walk a notes tree, calling `visit(target, blob)` for every well-formed entry.
262 + ///
263 + /// Paths that do not spell an object ID are skipped. Git does not promise a
264 + /// notes tree holds nothing else, and one stray file must not fail the page.
265 + ///
266 + /// Subtrees are collected per level and descended after the level's walk
267 + /// finishes, rather than recursing inside the visitor: it keeps the engine's
268 + /// borrow of the tree buffer to one level at a time, which is what lets the
269 + /// boundary hand out borrowed entry names at all.
270 + fn flatten<E: NoteObjects>(
271 + engine: &E,
272 + root_tree: Oid,
273 + visit: &mut impl FnMut(Oid, Oid),
274 + ) -> Result<(), NotesError> {
275 + let mut level: Vec<(Vec<u8>, Oid)> = vec![(Vec::new(), root_tree)];
276 +
277 + for _ in 0..MAX_FANOUT_DEPTH {
278 + if level.is_empty() {
279 + break;
280 + }
281 + let mut next_level = Vec::new();
282 +
283 + for (prefix, tree) in level {
284 + engine.read_tree_with(tree, &mut |entry: TreeEntry<'_>| {
285 + let mut path = Vec::with_capacity(prefix.len() + entry.name.len());
286 + path.extend_from_slice(&prefix);
287 + path.extend_from_slice(entry.name);
288 +
289 + match entry.kind {
290 + EntryKind::Blob => {
291 + if let Ok(target) = Oid::from_hex(&path) {
292 + visit(target, entry.oid);
293 + }
294 + }
295 + EntryKind::Tree => {
296 + // Only descend paths that could still become an ID.
297 + if path.len() < 64 && path.iter().all(u8::is_ascii_hexdigit) {
298 + next_level.push((path, entry.oid));
299 + }
300 + }
301 + EntryKind::Other => {}
302 + }
303 + Walk::Continue
304 + })?;
305 + }
306 + level = next_level;
307 + }
308 + Ok(())
309 + }
310 +
311 + // ── Attribution ──
312 +
313 + /// Find the notes commit that set `target`'s note to what it is now.
314 + ///
315 + /// The tree holds content, not history, so this walks the notes ref comparing
316 + /// each commit's value for `target` against its first parent's. Bounded by
317 + /// `max_commits` because a busy notes ref is long and this runs on a detail
318 + /// view, where one bounded walk is affordable and an unbounded one is not.
319 + pub fn attribution<E: NoteObjects>(
320 + engine: &E,
321 + tip: Oid,
322 + target: Oid,
323 + max_commits: usize,
324 + ) -> Result<Option<Attribution>, NotesError> {
325 + let mut current = tip;
326 + let mut meta = engine.read_commit(current)?;
327 + let Some(blob) = find_note_blob(engine, meta.tree, target)? else {
328 + return Ok(None);
329 + };
330 +
331 + let found = |commit: Oid, meta: CommitMeta, exact: bool| {
332 + Ok(Some(Attribution {
333 + note_commit: commit,
334 + blob,
335 + by: meta.committer,
336 + message: meta.message,
337 + exact,
338 + }))
339 + };
340 +
341 + for _ in 0..max_commits.max(1) {
342 + // A root commit introduced the note by definition. Only the first
343 + // parent is followed: a notes merge's second parent holds the other
344 + // side's value, and crediting it would name the wrong author.
345 + let Some(parent) = meta.parents.first().copied() else {
346 + return found(current, meta, true);
347 + };
348 + let parent_meta = engine.read_commit(parent)?;
349 + if find_note_blob(engine, parent_meta.tree, target)? != Some(blob) {
350 + return found(current, meta, true);
351 + }
352 + current = parent;
353 + meta = parent_meta;
354 + }
355 +
356 + found(current, meta, false)
357 + }
358 +
359 + // ── Cache ──
360 +
361 + /// Cache of flattened notes trees, keyed by the ref tip.
362 + ///
363 + /// Keying on the tip means a stale entry is not possible, only a missed one:
364 + /// when the ref moves the key moves with it, so there is no invalidation to get
365 + /// wrong. Old entries are evicted by pressure, not by age.
366 + ///
367 + /// The cache is a separate type rather than a global inside this module because
368 + /// the layer below it is meant to be liftable into a library, and a library
369 + /// should not own process-wide state. Callers hold one (P1 puts it in
370 + /// `AppState`) and the pure functions stay cache-free.
371 + pub struct NotesCache {
372 + entries: DashMap<CacheKey, Arc<HashMap<Oid, Oid>>>,
373 + max_entries: usize,
374 + }
375 +
376 + #[derive(Debug, Clone, PartialEq, Eq, Hash)]
377 + struct CacheKey {
378 + repo: String,
379 + namespace: String,
380 + tip: Oid,
381 + }
382 +
383 + impl NotesCache {
384 + pub fn new(max_entries: usize) -> Self {
385 + Self {
386 + entries: DashMap::new(),
387 + max_entries: max_entries.max(1),
388 + }
389 + }
390 +
391 + /// The flattened target-to-blob map for a namespace, computing it on a miss.
392 + ///
393 + /// `repo` identifies the repository (its on-disk path, in practice); the tip
394 + /// alone is not enough, since two repositories can share a notes tree after
395 + /// a fork.
396 + pub fn flattened<E: NoteObjects>(
397 + &self,
398 + engine: &E,
399 + repo: &str,
400 + namespace: &Namespace,
401 + ) -> Result<Arc<HashMap<Oid, Oid>>, NotesError> {
402 + let key = CacheKey {
403 + repo: repo.to_string(),
404 + namespace: namespace.name.clone(),
405 + tip: namespace.tip,
406 + };
407 + if let Some(hit) = self.entries.get(&key) {
408 + return Ok(Arc::clone(hit.value()));
409 + }
410 +
411 + let tree = engine.read_commit(namespace.tip)?.tree;
412 + let mut map = HashMap::new();
413 + flatten(engine, tree, &mut |target, blob| {
414 + map.insert(target, blob);
415 + })?;
416 + let map = Arc::new(map);
417 +
418 + self.evict_if_full();
419 + self.entries.insert(key, Arc::clone(&map));
420 + Ok(map)
421 + }
422 +
423 + /// Drop arbitrary entries once over capacity. Arbitrary is acceptable: this
424 + /// is a pure cache, every entry is equally recomputable, and the tip-keyed
425 + /// design means the cost of a wrong eviction is one extra tree walk.
426 + fn evict_if_full(&self) {
427 + while self.entries.len() >= self.max_entries {
428 + let victim = self.entries.iter().next().map(|e| e.key().clone());
429 + match victim {
430 + Some(key) => {
431 + self.entries.remove(&key);
432 + }
433 + None => break,
434 + }
435 + }
436 + }
437 +
438 + pub fn len(&self) -> usize {
439 + self.entries.len()
440 + }
441 +
442 + pub fn is_empty(&self) -> bool {
443 + self.entries.is_empty()
444 + }
445 + }
446 +
447 + impl Default for NotesCache {
448 + /// 256 flattened trees. A notes tree is a map of two object IDs per entry,
449 + /// so a large namespace is tens of KB and the ceiling is single-digit MB.
450 + fn default() -> Self {
451 + Self::new(256)
452 + }
453 + }
454 +
455 + #[cfg(test)]
456 + mod tests;
@@ -1,0 +1,430 @@
1 + //! Fixtures build notes trees by hand rather than through `git notes`, because
2 + //! the fanout shapes that matter (flat, `2/38`, `2/2/36`, and a tree holding
3 + //! more than one of them at once) are exactly what git decides for itself as a
4 + //! tree grows. Writing them directly is the only way to pin the reader against
5 + //! all of them without provoking a rebalance.
6 +
7 + use gix::objs::tree::EntryKind as GixEntryKind;
8 +
9 + use super::*;
10 +
11 + // Synthetic target IDs. Nothing looks these up as objects — a notes tree is
12 + // keyed by ID, and the reader never dereferences the target — so they need only
13 + // be well-formed hex. T1 and T2 share a leading `aa` so they exercise a shared
14 + // fanout directory.
15 + const T1: &str = "aabbccddeeff00112233445566778899aabbccdd";
16 + const T2: &str = "aabb00000000000000000000000000000000ffff";
17 + const T3: &str = "ccddeeff00112233445566778899aabbccddeeff";
18 +
19 + fn init_bare() -> (tempfile::TempDir, gix::Repository) {
20 + let tmp = tempfile::TempDir::new().unwrap();
21 + let path = tmp.path().join("owner").join("notes-test.git");
22 + std::fs::create_dir_all(&path).unwrap();
23 + let repo = gix::init_bare(&path).unwrap();
24 + (tmp, repo)
25 + }
26 +
27 + fn blob(repo: &gix::Repository, content: &str) -> gix::ObjectId {
28 + repo.write_blob(content.as_bytes()).unwrap().detach()
29 + }
30 +
31 + fn tree(repo: &gix::Repository, entries: &[(&str, gix::ObjectId, GixEntryKind)]) -> gix::ObjectId {
32 + let mut tree = gix::objs::Tree::empty();
33 + for (name, oid, kind) in entries {
34 + tree.entries.push(gix::objs::tree::Entry {
35 + mode: (*kind).into(),
36 + filename: (*name).into(),
37 + oid: *oid,
38 + });
39 + }
40 + tree.entries.sort();
41 + repo.write_object(&tree).unwrap().detach()
42 + }
43 +
44 + /// Commit a notes tree onto `ref_name`, crediting `who` so attribution has
45 + /// something to distinguish commits by.
46 + fn commit_notes(
47 + repo: &gix::Repository,
48 + ref_name: &str,
49 + who: &str,
50 + message: &str,
51 + tree: gix::ObjectId,
52 + parents: Vec<gix::ObjectId>,
53 + ) -> gix::ObjectId {
54 + let signature = gix::actor::SignatureRef {
55 + name: who.into(),
56 + email: "notes@example.com".into(),
57 + time: "1700000000 +0000",
58 + };
59 + repo.commit_as(signature, signature, ref_name, message, tree, parents)
60 + .unwrap()
61 + .detach()
62 + }
63 +
64 + fn oid(hex: &str) -> Oid {
65 + Oid::from_hex(hex.as_bytes()).unwrap()
66 + }
67 +
68 + fn namespace(engine: &GixEngine<'_>, name: &str) -> Namespace {
69 + resolve_namespace(engine, name).unwrap().unwrap()
70 + }
71 +
72 + // ── Fanout shapes ──
73 +
74 + /// One notes tree carrying all three fanout shapes at once: T1 flat at the
75 + /// root, T2 under `2/38`, T3 under `2/2/36`. Git produces mixed trees while
76 + /// rebalancing, and a reader that assumes one shape misses the others.
77 + fn mixed_fanout_repo() -> (tempfile::TempDir, gix::Repository) {
78 + let (tmp, repo) = init_bare();
79 +
80 + let n1 = blob(&repo, "note on one");
81 + let n2 = blob(&repo, "note on two");
82 + let n3 = blob(&repo, "note on three");
83 +
84 + // T2 as aa/bb00...ffff — shares the `aa` directory with nothing here, since
85 + // T1 is flat; the shared-prefix case gets its own test below.
86 + let t2_dir = tree(&repo, &[(&T2[2..], n2, GixEntryKind::Blob)]);
87 + // T3 as cc/dd/eeff...
88 + let t3_inner = tree(&repo, &[(&T3[4..], n3, GixEntryKind::Blob)]);
89 + let t3_outer = tree(&repo, &[(&T3[2..4], t3_inner, GixEntryKind::Tree)]);
90 +
91 + let root = tree(
92 + &repo,
93 + &[
94 + (T1, n1, GixEntryKind::Blob),
95 + (&T2[..2], t2_dir, GixEntryKind::Tree),
96 + (&T3[..2], t3_outer, GixEntryKind::Tree),
97 + ],
98 + );
99 + commit_notes(
100 + &repo,
101 + "refs/notes/commits",
102 + "Fixture",
103 + "notes: fixture",
104 + root,
105 + Vec::new(),
106 + );
107 + (tmp, repo)
108 + }
109 +
110 + #[test]
111 + fn reads_every_fanout_shape() {
112 + let (_tmp, repo) = mixed_fanout_repo();
113 + let engine = GixEngine::new(&repo);
114 + let ns = namespace(&engine, DEFAULT_NAMESPACE);
115 +
116 + for (target, expected) in [
117 + (T1, "note on one"),
118 + (T2, "note on two"),
119 + (T3, "note on three"),
120 + ] {
121 + let note = note_for(&engine, ns.tip, oid(target))
122 + .unwrap()
123 + .unwrap_or_else(|| panic!("no note found for {target}"));
124 + assert_eq!(note.content_lossy(), expected);
125 + assert_eq!(note.target, oid(target));
126 + }
127 + }
128 +
129 + #[test]
130 + fn absent_target_reads_as_none() {
131 + let (_tmp, repo) = mixed_fanout_repo();
132 + let engine = GixEngine::new(&repo);
133 + let ns = namespace(&engine, DEFAULT_NAMESPACE);
134 +
135 + let missing = "1111111111111111111111111111111111111111";
136 + assert!(note_for(&engine, ns.tip, oid(missing)).unwrap().is_none());
137 + }
138 +
139 + #[test]
140 + fn shared_fanout_directory_distinguishes_targets() {
141 + let (_tmp, repo) = init_bare();
142 + let n1 = blob(&repo, "first");
143 + let n2 = blob(&repo, "second");
144 +
145 + // Both under `aa/`, which is what git produces once two IDs collide on the
146 + // first byte. A descent that stops at the directory would confuse them.
147 + let shared = tree(
148 + &repo,
149 + &[
150 + (&T1[2..], n1, GixEntryKind::Blob),
151 + (&T2[2..], n2, GixEntryKind::Blob),
152 + ],
153 + );
154 + let root = tree(&repo, &[(&T1[..2], shared, GixEntryKind::Tree)]);
155 + commit_notes(
156 + &repo,
157 + "refs/notes/commits",
158 + "Fixture",
159 + "notes: shared prefix",
160 + root,
161 + Vec::new(),
162 + );
163 +
164 + let engine = GixEngine::new(&repo);
165 + let ns = namespace(&engine, DEFAULT_NAMESPACE);
166 + assert_eq!(
167 + note_for(&engine, ns.tip, oid(T1))
168 + .unwrap()
169 + .unwrap()
170 + .content_lossy(),
171 + "first"
172 + );
173 + assert_eq!(
174 + note_for(&engine, ns.tip, oid(T2))
175 + .unwrap()
176 + .unwrap()
177 + .content_lossy(),
178 + "second"
179 + );
180 + }
181 +
182 + #[test]
183 + fn non_note_paths_are_skipped_not_fatal() {
184 + let (_tmp, repo) = init_bare();
185 + let note = blob(&repo, "real note");
186 + let stray = blob(&repo, "not a note");
187 +
188 + // A README at the root and a non-hex directory. Git does not promise a
189 + // notes tree holds only notes, and one stray file must not fail the page.
190 + let junk_dir = tree(&repo, &[("whatever", stray, GixEntryKind::Blob)]);
191 + let root = tree(
192 + &repo,
193 + &[
194 + (T1, note, GixEntryKind::Blob),
195 + ("README", stray, GixEntryKind::Blob),
196 + ("zz", junk_dir, GixEntryKind::Tree),
197 + ],
198 + );
199 + commit_notes(
200 + &repo,
201 + "refs/notes/commits",
202 + "Fixture",
203 + "notes: with junk",
204 + root,
205 + Vec::new(),
206 + );
207 +
208 + let engine = GixEngine::new(&repo);
209 + let ns = namespace(&engine, DEFAULT_NAMESPACE);
210 + let all = annotated_targets(&engine, ns.tip).unwrap();
211 + assert_eq!(all.len(), 1, "only the well-formed path counts: {all:?}");
212 + assert_eq!(all[0].0, oid(T1));
213 + assert_eq!(count_notes(&engine, ns.tip).unwrap(), 1);
214 + }
215 +
216 + // ── Batch lookup ──
217 +
218 + #[test]
219 + fn notes_for_many_agrees_with_note_for() {
220 + let (_tmp, repo) = mixed_fanout_repo();
221 + let engine = GixEngine::new(&repo);
222 + let ns = namespace(&engine, DEFAULT_NAMESPACE);
223 +
224 + let absent = "1111111111111111111111111111111111111111";
225 + // Four targets takes the flatten path; two takes per-target descent. Both
226 + // have to produce the same answer or the log page and the commit page
227 + // disagree about whether a commit is annotated.
228 + let many = notes_for_many(&engine, ns.tip, &[oid(T1), oid(T2), oid(T3), oid(absent)]).unwrap();
229 + let few = notes_for_many(&engine, ns.tip, &[oid(T1), oid(absent)]).unwrap();
230 +
231 + assert_eq!(many.len(), 3);
232 + assert!(!many.contains_key(&oid(absent)));
233 + assert_eq!(few.len(), 1);
234 + assert_eq!(many.get(&oid(T1)), few.get(&oid(T1)));
235 +
236 + for target in [T1, T2, T3] {
237 + let single = note_for(&engine, ns.tip, oid(target)).unwrap().unwrap();
238 + assert_eq!(many.get(&oid(target)), Some(&single.blob));
239 + }
240 + }
241 +
242 + #[test]
243 + fn notes_for_many_with_no_targets_reads_nothing() {
244 + let (_tmp, repo) = mixed_fanout_repo();
245 + let engine = GixEngine::new(&repo);
246 + let ns = namespace(&engine, DEFAULT_NAMESPACE);
247 + assert!(notes_for_many(&engine, ns.tip, &[]).unwrap().is_empty());
248 + }
249 +
250 + // ── Namespaces ──
251 +
252 + #[test]
253 + fn namespaces_list_with_default_first() {
254 + let (_tmp, repo) = init_bare();
255 + let note = blob(&repo, "n");
256 + let root = tree(&repo, &[(T1, note, GixEntryKind::Blob)]);
257 +
258 + for ref_name in [
259 + "refs/notes/mnw/builds",
260 + "refs/notes/commits",
261 + "refs/notes/aaa",
262 + ] {
263 + commit_notes(&repo, ref_name, "Fixture", "notes: ns", root, Vec::new());
264 + }
265 +
266 + let engine = GixEngine::new(&repo);
267 + let names: Vec<String> = list_namespaces(&engine)
268 + .unwrap()
269 + .into_iter()
270 + .map(|n| n.name)
271 + .collect();
272 + // `commits` first because it is what a reader means by "the notes"; the
273 + // rest alphabetical.
274 + assert_eq!(names, vec!["commits", "aaa", "mnw/builds"]);
275 + }
276 +
277 + #[test]
278 + fn missing_namespace_is_none_not_an_error() {
279 + let (_tmp, repo) = init_bare();
280 + let engine = GixEngine::new(&repo);
281 + assert!(list_namespaces(&engine).unwrap().is_empty());
282 + assert!(
283 + resolve_namespace(&engine, DEFAULT_NAMESPACE)
284 + .unwrap()
285 + .is_none()
286 + );
287 + assert!(resolve_namespace(&engine, "nope").unwrap().is_none());
288 + }
289 +
290 + // ── Attribution ──
291 +
292 + /// Three notes commits: c1 adds T1, c2 adds T2, c3 rewrites T1.
293 + fn edited_notes_repo() -> (tempfile::TempDir, gix::Repository) {
294 + let (tmp, repo) = init_bare();
295 +
296 + let first = blob(&repo, "first version");
297 + let root1 = tree(&repo, &[(T1, first, GixEntryKind::Blob)]);
298 + let c1 = commit_notes(
299 + &repo,
300 + "refs/notes/commits",
301 + "Author One",
302 + "notes: add T1",
303 + root1,
304 + Vec::new(),
305 + );
306 +
307 + let other = blob(&repo, "unrelated");
308 + let root2 = tree(
309 + &repo,
310 + &[
311 + (T1, first, GixEntryKind::Blob),
312 + (T2, other, GixEntryKind::Blob),
313 + ],
314 + );
315 + let c2 = commit_notes(
316 + &repo,
317 + "refs/notes/commits",
318 + "Author Two",
319 + "notes: add T2",
320 + root2,
321 + vec![c1],
322 + );
323 +
324 + let second = blob(&repo, "second version");
325 + let root3 = tree(
326 + &repo,
327 + &[
328 + (T1, second, GixEntryKind::Blob),
329 + (T2, other, GixEntryKind::Blob),
330 + ],
331 + );
332 + commit_notes(
333 + &repo,
334 + "refs/notes/commits",
335 + "Author Three",
336 + "notes: edit T1",
337 + root3,
338 + vec![c2],
339 + );
340 + (tmp, repo)
341 + }
342 +
343 + #[test]
344 + fn attribution_names_the_commit_that_set_the_content() {
345 + let (_tmp, repo) = edited_notes_repo();
346 + let engine = GixEngine::new(&repo);
347 + let ns = namespace(&engine, DEFAULT_NAMESPACE);
348 +
349 + let t1 = attribution(&engine, ns.tip, oid(T1), 50).unwrap().unwrap();
350 + assert_eq!(t1.by.name, "Author Three", "the edit, not the original add");
351 + assert!(t1.exact);
352 +
353 + // T2 was untouched by the tip commit, so the walk has to go back past it.
354 + let t2 = attribution(&engine, ns.tip, oid(T2), 50).unwrap().unwrap();
355 + assert_eq!(t2.by.name, "Author Two");
356 + assert!(t2.exact);
357 + }
358 +
359 + #[test]
360 + fn attribution_is_none_for_an_unannotated_target() {
361 + let (_tmp, repo) = edited_notes_repo();
362 + let engine = GixEngine::new(&repo);
363 + let ns = namespace(&engine, DEFAULT_NAMESPACE);
364 + assert!(attribution(&engine, ns.tip, oid(T3), 50).unwrap().is_none());
365 + }
366 +
367 + #[test]
368 + fn attribution_marks_itself_inexact_when_the_budget_runs_out() {
369 + let (_tmp, repo) = edited_notes_repo();
370 + let engine = GixEngine::new(&repo);
371 + let ns = namespace(&engine, DEFAULT_NAMESPACE);
372 +
373 + // The tip commit set T1, so one step proves it: exact.
374 + let reached = attribution(&engine, ns.tip, oid(T1), 1).unwrap().unwrap();
375 + assert!(reached.exact);
376 + assert_eq!(reached.by.name, "Author Three");
377 +
378 + // T2 was already there at the tip, so one step only gets as far as the
379 + // commit before it without proving that one introduced it. `exact: false`
380 + // is that distinction — the commit named is the oldest examined, and here
381 + // it happens to be the right answer, which the walk cannot yet know.
382 + let bounded = attribution(&engine, ns.tip, oid(T2), 1).unwrap().unwrap();
383 + assert!(!bounded.exact);
384 +
385 + // Given the budget to finish, it proves the same commit and says so.
386 + let full = attribution(&engine, ns.tip, oid(T2), 50).unwrap().unwrap();
387 + assert!(full.exact);
388 + assert_eq!(full.note_commit, bounded.note_commit);
389 + }
390 +
391 + // ── Cache ──
392 +
393 + #[test]
394 + fn cache_returns_the_same_map_and_stays_bounded() {
395 + let (_tmp, repo) = mixed_fanout_repo();
396 + let engine = GixEngine::new(&repo);
397 + let ns = namespace(&engine, DEFAULT_NAMESPACE);
398 +
399 + let cache = NotesCache::new(2);
400 + let first = cache.flattened(&engine, "repo-a", &ns).unwrap();
401 + let second = cache.flattened(&engine, "repo-a", &ns).unwrap();
402 + assert!(Arc::ptr_eq(&first, &second), "second call should hit");
403 + assert_eq!(first.len(), 3);
404 +
405 + // Same tip, different repository: a fork shares the notes tree, so the tip
406 + // alone would collide across repos.
407 + cache.flattened(&engine, "repo-b", &ns).unwrap();
408 + cache.flattened(&engine, "repo-c", &ns).unwrap();
409 + assert!(
410 + cache.len() <= 2,
411 + "cache exceeded its ceiling: {}",
412 + cache.len()
413 + );
414 + }
415 +
416 + #[test]
417 + fn cache_agrees_with_an_uncached_walk() {
418 + let (_tmp, repo) = mixed_fanout_repo();
419 + let engine = GixEngine::new(&repo);
420 + let ns = namespace(&engine, DEFAULT_NAMESPACE);
421 +
422 + let cached = NotesCache::default()
423 + .flattened(&engine, "repo", &ns)
424 + .unwrap();
425 + let direct: HashMap<Oid, Oid> = annotated_targets(&engine, ns.tip)
426 + .unwrap()
427 + .into_iter()
428 + .collect();
429 + assert_eq!(*cached, direct);
430 + }