max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | + | } |