max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+926 insertions,
-39 deletions
| @@ -16,9 +16,9 @@ | |||
| 16 | 16 | //! engine and receives an [`Oid`] back. That keeps it out of the hashing | |
| 17 | 17 | //! business, and makes SHA-256 repositories a matter of the engine rather | |
| 18 | 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. | |
| 19 | + | //! 2. **Single-ref compare-and-swap only.** Multi-ref atomicity is | |
| 20 | + | //! engine-specific; promising it would make the trait unimplementable over a | |
| 21 | + | //! subprocess. | |
| 22 | 22 | //! | |
| 23 | 23 | //! Reads take a visitor or a caller-owned buffer rather than returning owned | |
| 24 | 24 | //! collections. A per-object callback boundary otherwise forfeits gix's | |
| @@ -112,14 +112,29 @@ | |||
| 112 | 112 | } | |
| 113 | 113 | } | |
| 114 | 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. | |
| 115 | + | /// What a tree entry points at. | |
| 116 | + | /// | |
| 117 | + | /// These are git's five legal tree entry modes and nothing else. The notes | |
| 118 | + | /// layer only cares whether an entry is a subtree or a blob, but it has to be | |
| 119 | + | /// able to write back entries it did not put there — a notes tree may carry | |
| 120 | + | /// unrelated paths, and dropping a symlink or a submodule while splicing a note | |
| 121 | + | /// would be data loss. A coarser enum could not round-trip them. | |
| 118 | 122 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 119 | 123 | pub enum EntryKind { | |
| 120 | 124 | Tree, | |
| 121 | 125 | Blob, | |
| 122 | - | Other, | |
| 126 | + | BlobExecutable, | |
| 127 | + | Symlink, | |
| 128 | + | Commit, | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | impl EntryKind { | |
| 132 | + | /// Whether this entry can hold note content. Git writes notes as plain | |
| 133 | + | /// blobs, but a tree assembled by other means may mark one executable, and | |
| 134 | + | /// a reader that ignored it would report the note missing. | |
| 135 | + | pub fn is_blob(self) -> bool { | |
| 136 | + | matches!(self, Self::Blob | Self::BlobExecutable) | |
| 137 | + | } | |
| 123 | 138 | } | |
| 124 | 139 | ||
| 125 | 140 | /// One entry in a tree, borrowed for the duration of the visit. | |
| @@ -132,6 +147,17 @@ | |||
| 132 | 147 | pub oid: Oid, | |
| 133 | 148 | } | |
| 134 | 149 | ||
| 150 | + | /// One entry of a tree about to be written. | |
| 151 | + | /// | |
| 152 | + | /// Owned, unlike [`TreeEntry`]: a splice reads a whole level, edits one entry | |
| 153 | + | /// and writes the level back, so the names have to outlive the read. | |
| 154 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 155 | + | pub struct NewEntry { | |
| 156 | + | pub name: Vec<u8>, | |
| 157 | + | pub kind: EntryKind, | |
| 158 | + | pub oid: Oid, | |
| 159 | + | } | |
| 160 | + | ||
| 135 | 161 | /// Whether a tree walk continues after the current entry. Lookups stop as soon | |
| 136 | 162 | /// as they have their answer rather than reading the rest of a fanout level. | |
| 137 | 163 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| @@ -173,6 +199,12 @@ | |||
| 173 | 199 | /// The repository holds something the notes convention does not allow. | |
| 174 | 200 | #[error("malformed notes data: {0}")] | |
| 175 | 201 | Malformed(String), | |
| 202 | + | /// A compare-and-swap ref update lost its race: the ref moved between the | |
| 203 | + | /// read that produced the expected value and the update. The caller reloads | |
| 204 | + | /// and retries; it is the ordinary outcome of two people annotating at | |
| 205 | + | /// once, not a fault. | |
| 206 | + | #[error("the notes ref moved during the update")] | |
| 207 | + | Raced, | |
| 176 | 208 | /// The engine failed. | |
| 177 | 209 | #[error("git backend error: {0}")] | |
| 178 | 210 | Backend(String), | |
| @@ -183,6 +215,7 @@ | |||
| 183 | 215 | match e { | |
| 184 | 216 | NotesError::NotFound => Self::RefNotFound, | |
| 185 | 217 | NotesError::Malformed(m) => Self::Git(format!("malformed notes data: {m}")), | |
| 218 | + | NotesError::Raced => Self::Git("the notes ref moved during the update".into()), | |
| 186 | 219 | NotesError::Backend(m) => Self::Git(m), | |
| 187 | 220 | } | |
| 188 | 221 | } | |
| @@ -190,9 +223,10 @@ | |||
| 190 | 223 | ||
| 191 | 224 | /// The read half of the engine boundary. | |
| 192 | 225 | /// | |
| 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. | |
| 226 | + | /// The write half is [`NoteWrites`], deliberately a second trait: the browsing | |
| 227 | + | /// path is read-only and must not require a writable repository to compile | |
| 228 | + | /// against, and an engine over a read-only mirror can implement this half | |
| 229 | + | /// alone. | |
| 196 | 230 | pub trait NoteObjects { | |
| 197 | 231 | /// Resolve a full ref name (`refs/notes/commits`) to the commit it points | |
| 198 | 232 | /// at, following annotated tags. `Ok(None)` means the ref does not exist, | |
| @@ -219,6 +253,53 @@ | |||
| 219 | 253 | fn read_blob_into(&self, oid: Oid, out: &mut Vec<u8>) -> Result<(), NotesError>; | |
| 220 | 254 | } | |
| 221 | 255 | ||
| 256 | + | /// The write half of the engine boundary. | |
| 257 | + | /// | |
| 258 | + | /// Every method hands bytes down and gets an [`Oid`] back. Nothing above this | |
| 259 | + | /// trait hashes anything, which is what keeps the notes layer out of the | |
| 260 | + | /// hashing business and makes a SHA-256 repository the engine's problem rather | |
| 261 | + | /// than a port. | |
| 262 | + | pub trait NoteWrites { | |
| 263 | + | /// Store a blob and return its ID. Storing content that is already present | |
| 264 | + | /// is not an error and returns the same ID — object stores are content | |
| 265 | + | /// addressed, so an unchanged note costs nothing. | |
| 266 | + | fn write_blob(&self, content: &[u8]) -> Result<Oid, NotesError>; | |
| 267 | + | ||
| 268 | + | /// Store a tree. `entries` may arrive in any order; putting them in git's | |
| 269 | + | /// canonical order is the engine's job, because the ordering rule (a | |
| 270 | + | /// subtree sorts as though its name ended in `/`) is a property of the | |
| 271 | + | /// object format rather than of notes. | |
| 272 | + | /// | |
| 273 | + | /// An empty slice writes the empty tree, which is what removing the last | |
| 274 | + | /// note in a namespace leaves behind. | |
| 275 | + | fn write_tree(&self, entries: &[NewEntry]) -> Result<Oid, NotesError>; | |
| 276 | + | ||
| 277 | + | /// Store a commit. This writes the object only: moving a ref onto it is | |
| 278 | + | /// [`update_ref_cas`](NoteWrites::update_ref_cas), separately, because the | |
| 279 | + | /// commit has to exist before anything can compare-and-swap onto it. | |
| 280 | + | fn write_commit( | |
| 281 | + | &self, | |
| 282 | + | tree: Oid, | |
| 283 | + | parents: &[Oid], | |
| 284 | + | author: &Signature, | |
| 285 | + | committer: &Signature, | |
| 286 | + | message: &str, | |
| 287 | + | ) -> Result<Oid, NotesError>; | |
| 288 | + | ||
| 289 | + | /// Point `full_name` at `new`, but only if it currently holds `expected` | |
| 290 | + | /// (`None` meaning the ref must not exist yet). | |
| 291 | + | /// | |
| 292 | + | /// [`NotesError::Raced`] when the ref holds something else. Single-ref | |
| 293 | + | /// only, by contract: multi-ref atomicity is engine-specific, and a trait | |
| 294 | + | /// that promised it could not be implemented over a `git` subprocess. | |
| 295 | + | fn update_ref_cas( | |
| 296 | + | &self, | |
| 297 | + | full_name: &str, | |
| 298 | + | expected: Option<Oid>, | |
| 299 | + | new: Oid, | |
| 300 | + | ) -> Result<(), NotesError>; | |
| 301 | + | } | |
| 302 | + | ||
| 222 | 303 | #[cfg(test)] | |
| 223 | 304 | mod tests { | |
| 224 | 305 | use super::*; |
| @@ -5,9 +5,11 @@ | |||
| 5 | 5 | //! this is the file that gets a sibling and nothing above it changes. | |
| 6 | 6 | ||
| 7 | 7 | use gix::bstr::ByteSlice; | |
| 8 | + | use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; | |
| 8 | 9 | ||
| 9 | 10 | use super::engine::{ | |
| 10 | - | CommitMeta, EntryKind, NoteObjects, NotesError, Oid, Signature, TreeEntry, Walk, | |
| 11 | + | CommitMeta, EntryKind, NewEntry, NoteObjects, NoteWrites, NotesError, Oid, Signature, | |
| 12 | + | TreeEntry, Walk, | |
| 11 | 13 | }; | |
| 12 | 14 | ||
| 13 | 15 | /// Adapts a borrowed [`gix::Repository`] to the notes engine boundary. | |
| @@ -53,10 +55,36 @@ | |||
| 53 | 55 | fn to_entry_kind(mode: gix::objs::tree::EntryMode) -> EntryKind { | |
| 54 | 56 | match mode.kind() { | |
| 55 | 57 | 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, | |
| 58 | + | gix::objs::tree::EntryKind::Blob => EntryKind::Blob, | |
| 59 | + | gix::objs::tree::EntryKind::BlobExecutable => EntryKind::BlobExecutable, | |
| 60 | + | gix::objs::tree::EntryKind::Link => EntryKind::Symlink, | |
| 61 | + | gix::objs::tree::EntryKind::Commit => EntryKind::Commit, | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | fn from_entry_kind(kind: EntryKind) -> gix::objs::tree::EntryKind { | |
| 66 | + | match kind { | |
| 67 | + | EntryKind::Tree => gix::objs::tree::EntryKind::Tree, | |
| 68 | + | EntryKind::Blob => gix::objs::tree::EntryKind::Blob, | |
| 69 | + | EntryKind::BlobExecutable => gix::objs::tree::EntryKind::BlobExecutable, | |
| 70 | + | EntryKind::Symlink => gix::objs::tree::EntryKind::Link, | |
| 71 | + | EntryKind::Commit => gix::objs::tree::EntryKind::Commit, | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /// Ours to gix's, for writing. The inverse of [`to_signature`], which is lossy | |
| 76 | + | /// about time zones by design — the notes layer speaks UTC, so a signature that | |
| 77 | + | /// survives a read-write round trip comes back with a `+0000` offset. Notes | |
| 78 | + | /// commits are written by the server, and the offset carries nothing a reader | |
| 79 | + | /// of them needs. | |
| 80 | + | fn from_signature(sig: &Signature) -> gix::actor::Signature { | |
| 81 | + | gix::actor::Signature { | |
| 82 | + | name: sig.name.as_str().into(), | |
| 83 | + | email: sig.email.as_str().into(), | |
| 84 | + | time: gix::date::Time { | |
| 85 | + | seconds: sig.time.timestamp(), | |
| 86 | + | offset: 0, | |
| 87 | + | }, | |
| 60 | 88 | } | |
| 61 | 89 | } | |
| 62 | 90 | ||
| @@ -170,3 +198,111 @@ | |||
| 170 | 198 | Ok(()) | |
| 171 | 199 | } | |
| 172 | 200 | } | |
| 201 | + | ||
| 202 | + | impl NoteWrites for GixEngine<'_> { | |
| 203 | + | fn write_blob(&self, content: &[u8]) -> Result<Oid, NotesError> { | |
| 204 | + | let id = self | |
| 205 | + | .repo | |
| 206 | + | .write_blob(content) | |
| 207 | + | .map_err(|e| NotesError::Backend(format!("cannot write note blob: {e}")))?; | |
| 208 | + | to_oid(&id) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | fn write_tree(&self, entries: &[NewEntry]) -> Result<Oid, NotesError> { | |
| 212 | + | let mut tree = gix::objs::Tree::empty(); | |
| 213 | + | for entry in entries { | |
| 214 | + | tree.entries.push(gix::objs::tree::Entry { | |
| 215 | + | mode: from_entry_kind(entry.kind).into(), | |
| 216 | + | filename: entry.name.as_slice().into(), | |
| 217 | + | oid: to_gix_oid(entry.oid)?, | |
| 218 | + | }); | |
| 219 | + | } | |
| 220 | + | // Git's ordering, which sorts a subtree as though its name ended in a | |
| 221 | + | // slash. `Ord` on the gix entry already implements that rule, and | |
| 222 | + | // getting it wrong produces a tree stock git rejects. | |
| 223 | + | tree.entries.sort(); | |
| 224 | + | let id = self | |
| 225 | + | .repo | |
| 226 | + | .write_object(&tree) | |
| 227 | + | .map_err(|e| NotesError::Backend(format!("cannot write notes tree: {e}")))?; | |
| 228 | + | to_oid(&id) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | fn write_commit( | |
| 232 | + | &self, | |
| 233 | + | tree: Oid, | |
| 234 | + | parents: &[Oid], | |
| 235 | + | author: &Signature, | |
| 236 | + | committer: &Signature, | |
| 237 | + | message: &str, | |
| 238 | + | ) -> Result<Oid, NotesError> { | |
| 239 | + | // Deliberately not `Repository::commit_as`, which writes the commit and | |
| 240 | + | // moves a ref in one call. The ref move is a compare-and-swap that has | |
| 241 | + | // to be able to fail and be retried against a reloaded tree, so the | |
| 242 | + | // object is written first and pointed at afterwards. | |
| 243 | + | let mut gix_parents = Vec::with_capacity(parents.len()); | |
| 244 | + | for parent in parents { | |
| 245 | + | gix_parents.push(to_gix_oid(*parent)?); | |
| 246 | + | } | |
| 247 | + | let commit = gix::objs::Commit { | |
| 248 | + | tree: to_gix_oid(tree)?, | |
| 249 | + | parents: gix_parents.into(), | |
| 250 | + | author: from_signature(author), | |
| 251 | + | committer: from_signature(committer), | |
| 252 | + | encoding: None, | |
| 253 | + | message: message.into(), | |
| 254 | + | extra_headers: Vec::new(), | |
| 255 | + | }; | |
| 256 | + | ||
| 257 | + | let id = self | |
| 258 | + | .repo | |
| 259 | + | .write_object(&commit) | |
| 260 | + | .map_err(|e| NotesError::Backend(format!("cannot write notes commit: {e}")))?; | |
| 261 | + | to_oid(&id) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | fn update_ref_cas( | |
| 265 | + | &self, | |
| 266 | + | full_name: &str, | |
| 267 | + | expected: Option<Oid>, | |
| 268 | + | new: Oid, | |
| 269 | + | ) -> Result<(), NotesError> { | |
| 270 | + | let expected_target = match expected { | |
| 271 | + | Some(oid) => { | |
| 272 | + | PreviousValue::MustExistAndMatch(gix::refs::Target::Object(to_gix_oid(oid)?)) | |
| 273 | + | } | |
| 274 | + | None => PreviousValue::MustNotExist, | |
| 275 | + | }; | |
| 276 | + | let edit = RefEdit { | |
| 277 | + | change: Change::Update { | |
| 278 | + | log: LogChange { | |
| 279 | + | mode: RefLog::AndReference, | |
| 280 | + | force_create_reflog: false, | |
| 281 | + | message: "notes: update from makenot.work".into(), | |
| 282 | + | }, | |
| 283 | + | expected: expected_target, | |
| 284 | + | new: gix::refs::Target::Object(to_gix_oid(new)?), | |
| 285 | + | }, | |
| 286 | + | name: full_name.try_into().map_err(|e| { | |
| 287 | + | NotesError::Malformed(format!("{full_name} is not a ref name: {e}")) | |
| 288 | + | })?, | |
| 289 | + | deref: false, | |
| 290 | + | }; | |
| 291 | + | ||
| 292 | + | let Err(err) = self.repo.edit_reference(edit) else { | |
| 293 | + | return Ok(()); | |
| 294 | + | }; | |
| 295 | + | // gix reports a failed precondition inside a transaction error whose | |
| 296 | + | // shape is not worth matching on. Re-reading the ref answers the only | |
| 297 | + | // question the caller has: did we lose the race, or is the repository | |
| 298 | + | // broken. A ref that moved again between the failure and this read | |
| 299 | + | // still reads as a race, which is the right answer. | |
| 300 | + | if self.resolve_ref(full_name)? == expected { | |
| 301 | + | Err(NotesError::Backend(format!( | |
| 302 | + | "cannot update {full_name}: {err}" | |
| 303 | + | ))) | |
| 304 | + | } else { | |
| 305 | + | Err(NotesError::Raced) | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | } |
| @@ -14,8 +14,9 @@ | |||
| 14 | 14 | //! this is going (a standalone crate, extracted once the P3 merge strategies | |
| 15 | 15 | //! exist and give it something no other implementation has). | |
| 16 | 16 | //! | |
| 17 | - | //! Read-only. The write path, its compare-and-swap ref update, and the merge | |
| 18 | - | //! strategies arrive in P2 and P3. | |
| 17 | + | //! The write path splices one note into a tree and hands the result back; the | |
| 18 | + | //! compare-and-swap that publishes it, and the merge strategies, follow. See | |
| 19 | + | //! "Splicing" below for why the shape of the tree we write is not arbitrary. | |
| 19 | 20 | ||
| 20 | 21 | pub mod engine; | |
| 21 | 22 | pub mod gix_engine; | |
| @@ -25,7 +26,7 @@ | |||
| 25 | 26 | ||
| 26 | 27 | use dashmap::DashMap; | |
| 27 | 28 | ||
| 28 | - | pub use engine::{CommitMeta, NoteObjects, NotesError, Oid, Signature}; | |
| 29 | + | pub use engine::{CommitMeta, NewEntry, NoteObjects, NoteWrites, NotesError, Oid, Signature}; | |
| 29 | 30 | pub use gix_engine::GixEngine; | |
| 30 | 31 | ||
| 31 | 32 | use engine::{EntryKind, TreeEntry, Walk}; | |
| @@ -232,15 +233,13 @@ | |||
| 232 | 233 | let mut subtree: Option<Oid> = None; | |
| 233 | 234 | ||
| 234 | 235 | 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 | - | _ => {} | |
| 236 | + | if entry.kind.is_blob() && entry.name == remaining { | |
| 237 | + | leaf = Some(entry.oid); | |
| 238 | + | return Walk::Stop; | |
| 239 | + | } | |
| 240 | + | if entry.kind == EntryKind::Tree && remaining.len() > 2 && entry.name == &remaining[..2] | |
| 241 | + | { | |
| 242 | + | subtree = Some(entry.oid); | |
| 244 | 243 | } | |
| 245 | 244 | Walk::Continue | |
| 246 | 245 | })?; | |
| @@ -286,19 +285,15 @@ | |||
| 286 | 285 | path.extend_from_slice(&prefix); | |
| 287 | 286 | path.extend_from_slice(entry.name); | |
| 288 | 287 | ||
| 289 | - | match entry.kind { | |
| 290 | - | EntryKind::Blob => { | |
| 291 | - | if let Ok(target) = Oid::from_hex(&path) { | |
| 292 | - | visit(target, entry.oid); | |
| 293 | - | } | |
| 288 | + | if entry.kind == EntryKind::Tree { | |
| 289 | + | // Only descend paths that could still become an ID. | |
| 290 | + | if path.len() < 64 && path.iter().all(u8::is_ascii_hexdigit) { | |
| 291 | + | next_level.push((path, entry.oid)); | |
| 294 | 292 | } | |
| 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 => {} | |
| 293 | + | } else if entry.kind.is_blob() | |
| 294 | + | && let Ok(target) = Oid::from_hex(&path) | |
| 295 | + | { | |
| 296 | + | visit(target, entry.oid); | |
| 302 | 297 | } | |
| 303 | 298 | Walk::Continue | |
| 304 | 299 | })?; | |
| @@ -308,6 +303,273 @@ | |||
| 308 | 303 | Ok(()) | |
| 309 | 304 | } | |
| 310 | 305 | ||
| 306 | + | // ── Splicing ── | |
| 307 | + | ||
| 308 | + | /// What splicing one level of a notes tree produced. | |
| 309 | + | enum Spliced { | |
| 310 | + | /// The level is exactly as it was. Nothing above it needs rewriting either. | |
| 311 | + | Same, | |
| 312 | + | /// The level was rewritten and now has this ID. | |
| 313 | + | Changed(Oid), | |
| 314 | + | /// A delete emptied the level. The parent drops the directory entry rather | |
| 315 | + | /// than writing an empty tree, which git does not leave lying around inside | |
| 316 | + | /// a notes tree. | |
| 317 | + | Emptied, | |
| 318 | + | /// A delete left the level holding one note and nothing else, so it folds | |
| 319 | + | /// back into the parent as a leaf: the parent replaces its directory entry | |
| 320 | + | /// with a blob named its own name plus this suffix. Git consolidates the | |
| 321 | + | /// same way, and a tree that kept the directory would slowly accumulate | |
| 322 | + | /// one-entry fanout levels that no delete ever cleaned up. | |
| 323 | + | /// | |
| 324 | + | /// Only a delete folds. An edit that happens to pass through a directory | |
| 325 | + | /// holding one note leaves it exactly as it found it — reshaping a tree | |
| 326 | + | /// nobody asked about would make every writer fight over the layout. | |
| 327 | + | Collapsed(Vec<u8>, Oid), | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | /// Set or remove the note on `target`, returning the new root tree. | |
| 331 | + | /// | |
| 332 | + | /// `root_tree` is `None` for a namespace that does not exist yet. `blob` is the | |
| 333 | + | /// content blob to point at, or `None` to remove the note. `Ok(None)` means the | |
| 334 | + | /// tree already said what was asked for and nothing was written — an unchanged | |
| 335 | + | /// note must not produce a commit. | |
| 336 | + | /// | |
| 337 | + | /// **Shape matters.** Git decides the fanout of a notes tree by insertion: a | |
| 338 | + | /// directory exists exactly where two notes collided on a prefix, and a delete | |
| 339 | + | /// that leaves one behind folds it back. Following the same rule keeps the tree | |
| 340 | + | /// stable across writers, so a `git notes` write and one of ours produce the | |
| 341 | + | /// same shape rather than each rearranging the other's work. Nothing breaks if | |
| 342 | + | /// the shape drifts — every reader, ours included, handles any fanout — but a | |
| 343 | + | /// drifting shape churns the tree for no reason. | |
| 344 | + | /// | |
| 345 | + | /// Entries the notes convention does not explain are carried through untouched, | |
| 346 | + | /// at every level. A notes tree is allowed to hold other paths, and a splice | |
| 347 | + | /// that dropped them would be data loss. | |
| 348 | + | pub fn splice_note<E: NoteObjects + NoteWrites>( | |
| 349 | + | engine: &E, | |
| 350 | + | root_tree: Option<Oid>, | |
| 351 | + | target: Oid, | |
| 352 | + | blob: Option<Oid>, | |
| 353 | + | ) -> Result<Option<Oid>, NotesError> { | |
| 354 | + | let hex = target.to_hex(); | |
| 355 | + | let entries = match root_tree { | |
| 356 | + | Some(tree) => read_entries(engine, tree)?, | |
| 357 | + | None => Vec::new(), | |
| 358 | + | }; | |
| 359 | + | ||
| 360 | + | match splice_level(engine, entries, hex.as_bytes(), blob, 0)? { | |
| 361 | + | Spliced::Same => Ok(None), | |
| 362 | + | Spliced::Changed(oid) => Ok(Some(oid)), | |
| 363 | + | // Only produced for `depth > 0`; the root is written even when empty, | |
| 364 | + | // because removing the last note still needs a tree to commit. | |
| 365 | + | Spliced::Emptied | Spliced::Collapsed(..) => Err(NotesError::Malformed( | |
| 366 | + | "the notes root tree cannot fold into a parent".into(), | |
| 367 | + | )), | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | /// Read one tree level into owned entries so it can be edited and written back. | |
| 372 | + | fn read_entries<E: NoteObjects>(engine: &E, tree: Oid) -> Result<Vec<NewEntry>, NotesError> { | |
| 373 | + | let mut entries = Vec::new(); | |
| 374 | + | engine.read_tree_with(tree, &mut |entry: TreeEntry<'_>| { | |
| 375 | + | entries.push(NewEntry { | |
| 376 | + | name: entry.name.to_vec(), | |
| 377 | + | kind: entry.kind, | |
| 378 | + | oid: entry.oid, | |
| 379 | + | }); | |
| 380 | + | Walk::Continue | |
| 381 | + | })?; | |
| 382 | + | Ok(entries) | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | /// Splice `blob` in at the level whose entries are `entries`, where `remaining` | |
| 386 | + | /// is the part of the target's hex this level and everything below it spells. | |
| 387 | + | fn splice_level<E: NoteObjects + NoteWrites>( | |
| 388 | + | engine: &E, | |
| 389 | + | mut entries: Vec<NewEntry>, | |
| 390 | + | remaining: &[u8], | |
| 391 | + | blob: Option<Oid>, | |
| 392 | + | depth: usize, | |
| 393 | + | ) -> Result<Spliced, NotesError> { | |
| 394 | + | // Deeper than any legitimate fanout. The reader gives up here too, so | |
| 395 | + | // writing below it would put a note where nothing can find it. | |
| 396 | + | if depth >= MAX_FANOUT_DEPTH { | |
| 397 | + | return Ok(Spliced::Same); | |
| 398 | + | } | |
| 399 | + | let deleting = blob.is_none(); | |
| 400 | + | ||
| 401 | + | // The note already lives at this level, spelled out in full. Same order as | |
| 402 | + | // the reader: leaf first, then the directory. | |
| 403 | + | if let Some(at) = entries | |
| 404 | + | .iter() | |
| 405 | + | .position(|e| e.kind.is_blob() && e.name == remaining) | |
| 406 | + | { | |
| 407 | + | match blob { | |
| 408 | + | Some(new) if entries[at].oid == new && entries[at].kind == EntryKind::Blob => { | |
| 409 | + | return Ok(Spliced::Same); | |
| 410 | + | } | |
| 411 | + | Some(new) => { | |
| 412 | + | entries[at].oid = new; | |
| 413 | + | // Normalize the mode. Git writes notes as plain blobs, and an | |
| 414 | + | // executable one is a leftover from something else. | |
| 415 | + | entries[at].kind = EntryKind::Blob; | |
| 416 | + | } | |
| 417 | + | None => { | |
| 418 | + | entries.remove(at); | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | return finish_level(engine, &entries, remaining.len(), depth, deleting); | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | // A fanout directory for the next two characters. | |
| 425 | + | if remaining.len() > 2 { | |
| 426 | + | let head = &remaining[..2]; | |
| 427 | + | if let Some(at) = entries | |
| 428 | + | .iter() | |
| 429 | + | .position(|e| e.kind == EntryKind::Tree && e.name == head) | |
| 430 | + | { | |
| 431 | + | let below = read_entries(engine, entries[at].oid)?; | |
| 432 | + | match splice_level(engine, below, &remaining[2..], blob, depth + 1)? { | |
| 433 | + | Spliced::Same => return Ok(Spliced::Same), | |
| 434 | + | Spliced::Changed(oid) => entries[at].oid = oid, | |
| 435 | + | Spliced::Emptied => { | |
| 436 | + | entries.remove(at); | |
| 437 | + | } | |
| 438 | + | Spliced::Collapsed(suffix, oid) => { | |
| 439 | + | let mut name = head.to_vec(); | |
| 440 | + | name.extend_from_slice(&suffix); | |
| 441 | + | entries[at] = NewEntry { | |
| 442 | + | name, | |
| 443 | + | kind: EntryKind::Blob, | |
| 444 | + | oid, | |
| 445 | + | }; | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | return finish_level(engine, &entries, remaining.len(), depth, deleting); | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | // The note is not in this tree at all. | |
| 453 | + | let Some(new) = blob else { | |
| 454 | + | // Removing a note that was never there is not an error. Two people | |
| 455 | + | // deleting the same note both succeed, and neither writes a commit. | |
| 456 | + | return Ok(Spliced::Same); | |
| 457 | + | }; | |
| 458 | + | ||
| 459 | + | // A note already sitting flat at this level whose ID shares our first two | |
| 460 | + | // characters. Git splits the pair into a directory rather than leaving two | |
| 461 | + | // colliding names side by side; this is the only place fanout is created. | |
| 462 | + | if remaining.len() > 2 { | |
| 463 | + | let head = &remaining[..2]; | |
| 464 | + | if let Some(at) = entries.iter().position(|e| { | |
| 465 | + | e.kind.is_blob() | |
| 466 | + | && e.name.len() == remaining.len() | |
| 467 | + | && e.name.starts_with(head) | |
| 468 | + | && e.name.iter().all(u8::is_ascii_hexdigit) | |
| 469 | + | }) { | |
| 470 | + | let neighbour = entries.remove(at); | |
| 471 | + | let subtree = split_pair( | |
| 472 | + | engine, | |
| 473 | + | &neighbour.name[2..], | |
| 474 | + | neighbour.oid, | |
| 475 | + | &remaining[2..], | |
| 476 | + | new, | |
| 477 | + | depth + 1, | |
| 478 | + | )?; | |
| 479 | + | entries.push(NewEntry { | |
| 480 | + | name: head.to_vec(), | |
| 481 | + | kind: EntryKind::Tree, | |
| 482 | + | oid: subtree, | |
| 483 | + | }); | |
| 484 | + | return finish_level(engine, &entries, remaining.len(), depth, deleting); | |
| 485 | + | } | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | entries.push(NewEntry { | |
| 489 | + | name: remaining.to_vec(), | |
| 490 | + | kind: EntryKind::Blob, | |
| 491 | + | oid: new, | |
| 492 | + | }); | |
| 493 | + | finish_level(engine, &entries, remaining.len(), depth, deleting) | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | /// Write a level back, or tell the parent to absorb it. | |
| 497 | + | /// | |
| 498 | + | /// `leaf_len` is how long a note's name is at this level, which is what makes | |
| 499 | + | /// folding safe: a shorter name would not spell a whole object ID once hoisted, | |
| 500 | + | /// so a hand-built tree holding one cannot be collapsed into an unreadable one. | |
| 501 | + | fn finish_level<E: NoteWrites>( | |
| 502 | + | engine: &E, | |
| 503 | + | entries: &[NewEntry], | |
| 504 | + | leaf_len: usize, | |
| 505 | + | depth: usize, | |
| 506 | + | deleting: bool, | |
| 507 | + | ) -> Result<Spliced, NotesError> { | |
| 508 | + | if depth > 0 && deleting { | |
| 509 | + | if entries.is_empty() { | |
| 510 | + | return Ok(Spliced::Emptied); | |
| 511 | + | } | |
| 512 | + | let [only] = entries else { | |
| 513 | + | return Ok(Spliced::Changed(engine.write_tree(entries)?)); | |
| 514 | + | }; | |
| 515 | + | if only.kind.is_blob() | |
| 516 | + | && only.name.len() == leaf_len | |
| 517 | + | && only.name.iter().all(u8::is_ascii_hexdigit) | |
| 518 | + | { | |
| 519 | + | return Ok(Spliced::Collapsed(only.name.clone(), only.oid)); | |
| 520 | + | } | |
| 521 | + | } | |
| 522 | + | Ok(Spliced::Changed(engine.write_tree(entries)?)) | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | /// Build the fanout directory that holds two notes whose IDs collide, adding a | |
| 526 | + | /// level per shared pair of characters until they diverge. | |
| 527 | + | fn split_pair<E: NoteWrites>( | |
| 528 | + | engine: &E, | |
| 529 | + | left_name: &[u8], | |
| 530 | + | left: Oid, | |
| 531 | + | right_name: &[u8], | |
| 532 | + | right: Oid, | |
| 533 | + | depth: usize, | |
| 534 | + | ) -> Result<Oid, NotesError> { | |
| 535 | + | if left_name == right_name { | |
| 536 | + | // Two different targets cannot spell the same ID; a tree that says so | |
| 537 | + | // is corrupt, and writing it would drop one of the two notes. | |
| 538 | + | return Err(NotesError::Malformed( | |
| 539 | + | "two notes claim the same object id".into(), | |
| 540 | + | )); | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | if depth < MAX_FANOUT_DEPTH && left_name.len() > 2 && left_name[..2] == right_name[..2] { | |
| 544 | + | let inner = split_pair( | |
| 545 | + | engine, | |
| 546 | + | &left_name[2..], | |
| 547 | + | left, | |
| 548 | + | &right_name[2..], | |
| 549 | + | right, | |
| 550 | + | depth + 1, | |
| 551 | + | )?; | |
| 552 | + | return engine.write_tree(&[NewEntry { | |
| 553 | + | name: left_name[..2].to_vec(), | |
| 554 | + | kind: EntryKind::Tree, | |
| 555 | + | oid: inner, | |
| 556 | + | }]); | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | engine.write_tree(&[ | |
| 560 | + | NewEntry { | |
| 561 | + | name: left_name.to_vec(), | |
| 562 | + | kind: EntryKind::Blob, | |
| 563 | + | oid: left, | |
| 564 | + | }, | |
| 565 | + | NewEntry { | |
| 566 | + | name: right_name.to_vec(), | |
| 567 | + | kind: EntryKind::Blob, | |
| 568 | + | oid: right, | |
| 569 | + | }, | |
| 570 | + | ]) | |
| 571 | + | } | |
| 572 | + | ||
| 311 | 573 | // ── Attribution ── | |
| 312 | 574 | ||
| 313 | 575 | /// Find the notes commit that set `target`'s note to what it is now. |
| @@ -65,6 +65,60 @@ | |||
| 65 | 65 | Oid::from_hex(hex.as_bytes()).unwrap() | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | fn ours(id: gix::ObjectId) -> Oid { | |
| 69 | + | Oid::from_bytes(id.as_bytes()).unwrap() | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// Every note path in a tree, spelled the way it sits on disk (`aa/bb/ccdd…`). | |
| 73 | + | /// | |
| 74 | + | /// The write tests assert on this rather than on content alone: two trees can | |
| 75 | + | /// hold the same notes in different fanout shapes, and the shape is the part | |
| 76 | + | /// that has to match what git would have produced. | |
| 77 | + | fn paths(engine: &GixEngine<'_>, tree: Oid) -> Vec<String> { | |
| 78 | + | let mut out = Vec::new(); | |
| 79 | + | let mut pending = vec![(String::new(), tree)]; | |
| 80 | + | while let Some((prefix, tree)) = pending.pop() { | |
| 81 | + | let mut children = Vec::new(); | |
| 82 | + | engine | |
| 83 | + | .read_tree_with(tree, &mut |entry: TreeEntry<'_>| { | |
| 84 | + | let name = format!("{prefix}{}", String::from_utf8_lossy(entry.name)); | |
| 85 | + | if entry.kind == EntryKind::Tree { | |
| 86 | + | children.push((format!("{name}/"), entry.oid)); | |
| 87 | + | } else { | |
| 88 | + | out.push(name); | |
| 89 | + | } | |
| 90 | + | Walk::Continue | |
| 91 | + | }) | |
| 92 | + | .unwrap(); | |
| 93 | + | pending.extend(children); | |
| 94 | + | } | |
| 95 | + | out.sort(); | |
| 96 | + | out | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// The target-to-blob map of a tree, without needing a commit to hang it off. | |
| 100 | + | fn flat(engine: &GixEngine<'_>, tree: Oid) -> Vec<(Oid, Oid)> { | |
| 101 | + | let mut out = Vec::new(); | |
| 102 | + | flatten(engine, tree, &mut |target, blob| out.push((target, blob))).unwrap(); | |
| 103 | + | out.sort_unstable(); | |
| 104 | + | out | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | fn signature(name: &str) -> Signature { | |
| 108 | + | Signature { | |
| 109 | + | name: name.to_string(), | |
| 110 | + | email: format!("{name}@users.makenot.work"), | |
| 111 | + | time: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(), | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// The root tree of a repository's default namespace, or `None` if nobody has | |
| 116 | + | /// annotated anything yet. | |
| 117 | + | fn root_tree(engine: &GixEngine<'_>) -> Option<Oid> { | |
| 118 | + | let ns = resolve_namespace(engine, DEFAULT_NAMESPACE).unwrap()?; | |
| 119 | + | Some(engine.read_commit(ns.tip).unwrap().tree) | |
| 120 | + | } | |
| 121 | + | ||
| 68 | 122 | fn namespace(engine: &GixEngine<'_>, name: &str) -> Namespace { | |
| 69 | 123 | resolve_namespace(engine, name).unwrap().unwrap() | |
| 70 | 124 | } | |
| @@ -413,6 +467,360 @@ | |||
| 413 | 467 | ); | |
| 414 | 468 | } | |
| 415 | 469 | ||
| 470 | + | // ── Splicing ── | |
| 471 | + | ||
| 472 | + | #[test] | |
| 473 | + | fn a_first_note_lands_flat_at_the_root() { | |
| 474 | + | let (_tmp, repo) = init_bare(); | |
| 475 | + | let engine = GixEngine::new(&repo); | |
| 476 | + | let content = ours(blob(&repo, "first note in the repository")); | |
| 477 | + | ||
| 478 | + | let root = splice_note(&engine, None, oid(T1), Some(content)) | |
| 479 | + | .unwrap() | |
| 480 | + | .expect("a new tree"); | |
| 481 | + | ||
| 482 | + | // No fanout: one note has nothing to collide with, and git does not create | |
| 483 | + | // a directory speculatively. | |
| 484 | + | assert_eq!(paths(&engine, root), vec![T1.to_string()]); | |
| 485 | + | assert_eq!(flat(&engine, root), vec![(oid(T1), content)]); | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | #[test] | |
| 489 | + | fn a_colliding_note_splits_the_pair_down_to_where_they_differ() { | |
| 490 | + | let (_tmp, repo) = init_bare(); | |
| 491 | + | let engine = GixEngine::new(&repo); | |
| 492 | + | let one = ours(blob(&repo, "one")); | |
| 493 | + | let two = ours(blob(&repo, "two")); | |
| 494 | + | ||
| 495 | + | let first = splice_note(&engine, None, oid(T1), Some(one)) | |
| 496 | + | .unwrap() | |
| 497 | + | .unwrap(); | |
| 498 | + | let both = splice_note(&engine, Some(first), oid(T2), Some(two)) | |
| 499 | + | .unwrap() | |
| 500 | + | .unwrap(); | |
| 501 | + | ||
| 502 | + | // T1 and T2 share `aabb`, so one directory is not enough to separate them | |
| 503 | + | // and git adds a second. Splitting only as far as the first difference is | |
| 504 | + | // what keeps the tree from growing a level per byte. | |
| 505 | + | assert_eq!( | |
| 506 | + | paths(&engine, both), | |
| 507 | + | vec![format!("aa/bb/{}", &T2[4..]), format!("aa/bb/{}", &T1[4..]),] | |
| 508 | + | .into_iter() | |
| 509 | + | .collect::<std::collections::BTreeSet<_>>() | |
| 510 | + | .into_iter() | |
| 511 | + | .collect::<Vec<_>>() | |
| 512 | + | ); | |
| 513 | + | assert_eq!(flat(&engine, both), { | |
| 514 | + | let mut want = vec![(oid(T1), one), (oid(T2), two)]; | |
| 515 | + | want.sort_unstable(); | |
| 516 | + | want | |
| 517 | + | }); | |
| 518 | + | ||
| 519 | + | // A third note that shares nothing stays flat beside the directory. | |
| 520 | + | let three = ours(blob(&repo, "three")); | |
| 521 | + | let all = splice_note(&engine, Some(both), oid(T3), Some(three)) | |
| 522 | + | .unwrap() | |
| 523 | + | .unwrap(); | |
| 524 | + | assert!(paths(&engine, all).contains(&T3.to_string())); | |
| 525 | + | assert_eq!(flat(&engine, all).len(), 3); | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | #[test] | |
| 529 | + | fn an_edit_replaces_the_note_where_it_already_sits() { | |
| 530 | + | let (_tmp, repo) = mixed_fanout_repo(); | |
| 531 | + | let engine = GixEngine::new(&repo); | |
| 532 | + | let before = root_tree(&engine).unwrap(); | |
| 533 | + | let shape = paths(&engine, before); | |
| 534 | + | ||
| 535 | + | // T2 lives at `aa/bb00…` in this fixture and T3 at `cc/dd/eeff…`. An edit | |
| 536 | + | // moves neither: rewriting a note at a different depth than it was found | |
| 537 | + | // would leave the old path behind and the note doubled. | |
| 538 | + | let mut tree = before; | |
| 539 | + | for target in [T2, T3] { | |
| 540 | + | let replacement = ours(blob(&repo, &format!("rewritten note on {target}"))); | |
| 541 | + | tree = splice_note(&engine, Some(tree), oid(target), Some(replacement)) | |
| 542 | + | .unwrap() | |
| 543 | + | .expect("the tree changed"); | |
| 544 | + | assert_eq!(paths(&engine, tree), shape, "editing {target} moved it"); | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | assert_eq!(flat(&engine, tree).len(), 3); | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | #[test] | |
| 551 | + | fn writing_the_same_content_back_changes_nothing() { | |
| 552 | + | let (_tmp, repo) = mixed_fanout_repo(); | |
| 553 | + | let engine = GixEngine::new(&repo); | |
| 554 | + | let root = root_tree(&engine).unwrap(); | |
| 555 | + | ||
| 556 | + | // Blobs are content addressed, so re-submitting the same text produces the | |
| 557 | + | // blob that is already there. Reporting a change would commit an empty diff | |
| 558 | + | // to the notes ref every time somebody pressed save without editing. | |
| 559 | + | let same = ours(blob(&repo, "note on two")); | |
| 560 | + | assert!( | |
| 561 | + | splice_note(&engine, Some(root), oid(T2), Some(same)) | |
| 562 | + | .unwrap() | |
| 563 | + | .is_none() | |
| 564 | + | ); | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | #[test] | |
| 568 | + | fn deleting_a_note_prunes_the_directory_it_emptied() { | |
| 569 | + | let (_tmp, repo) = mixed_fanout_repo(); | |
| 570 | + | let engine = GixEngine::new(&repo); | |
| 571 | + | let root = root_tree(&engine).unwrap(); | |
| 572 | + | ||
| 573 | + | // T3 is the only note under `cc/`, two levels down. Both levels go: an | |
| 574 | + | // empty fanout directory is a path git would never have written. | |
| 575 | + | let after = splice_note(&engine, Some(root), oid(T3), None) | |
| 576 | + | .unwrap() | |
| 577 | + | .expect("the tree changed"); | |
| 578 | + | ||
| 579 | + | assert!( | |
| 580 | + | !paths(&engine, after).iter().any(|p| p.starts_with("cc")), | |
| 581 | + | "the emptied directory survived: {:?}", | |
| 582 | + | paths(&engine, after) | |
| 583 | + | ); | |
| 584 | + | assert_eq!(flat(&engine, after).len(), 2); | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | #[test] | |
| 588 | + | fn deleting_a_note_folds_a_directory_left_holding_one() { | |
| 589 | + | let (_tmp, repo) = init_bare(); | |
| 590 | + | let engine = GixEngine::new(&repo); | |
| 591 | + | let one = ours(blob(&repo, "one")); | |
| 592 | + | let two = ours(blob(&repo, "two")); | |
| 593 | + | ||
| 594 | + | let tree = splice_note(&engine, None, oid(T1), Some(one)) | |
| 595 | + | .unwrap() | |
| 596 | + | .unwrap(); | |
| 597 | + | let tree = splice_note(&engine, Some(tree), oid(T2), Some(two)) | |
| 598 | + | .unwrap() | |
| 599 | + | .unwrap(); | |
| 600 | + | let tree = splice_note(&engine, Some(tree), oid(T1), None) | |
| 601 | + | .unwrap() | |
| 602 | + | .expect("the tree changed"); | |
| 603 | + | ||
| 604 | + | // Both levels of `aa/bb/` existed only to separate the two. With one left | |
| 605 | + | // the tree folds all the way back to flat, which is the shape it would have | |
| 606 | + | // had if T2 had been the only note from the start — so an add followed by a | |
| 607 | + | // delete leaves no trace of the pair. | |
| 608 | + | assert_eq!(paths(&engine, tree), vec![T2.to_string()]); | |
| 609 | + | assert_eq!(flat(&engine, tree), vec![(oid(T2), two)]); | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | #[test] | |
| 613 | + | fn deleting_the_last_note_leaves_an_empty_tree() { | |
| 614 | + | let (_tmp, repo) = init_bare(); | |
| 615 | + | let engine = GixEngine::new(&repo); | |
| 616 | + | let content = ours(blob(&repo, "the only note")); | |
| 617 | + | ||
| 618 | + | let tree = splice_note(&engine, None, oid(T1), Some(content)) | |
| 619 | + | .unwrap() | |
| 620 | + | .unwrap(); | |
| 621 | + | let empty = splice_note(&engine, Some(tree), oid(T1), None) | |
| 622 | + | .unwrap() | |
| 623 | + | .expect("the tree changed"); | |
| 624 | + | ||
| 625 | + | // An empty tree, not a missing one: removing the last note still commits, | |
| 626 | + | // so the namespace keeps its history rather than vanishing. | |
| 627 | + | assert!(paths(&engine, empty).is_empty()); | |
| 628 | + | assert!(flat(&engine, empty).is_empty()); | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | #[test] | |
| 632 | + | fn deleting_a_note_that_was_never_there_is_not_a_change() { | |
| 633 | + | let (_tmp, repo) = mixed_fanout_repo(); | |
| 634 | + | let engine = GixEngine::new(&repo); | |
| 635 | + | let root = root_tree(&engine).unwrap(); | |
| 636 | + | ||
| 637 | + | let absent = "1111111111111111111111111111111111111111"; | |
| 638 | + | assert!( | |
| 639 | + | splice_note(&engine, Some(root), oid(absent), None) | |
| 640 | + | .unwrap() | |
| 641 | + | .is_none() | |
| 642 | + | ); | |
| 643 | + | // And on a namespace that does not exist at all. | |
| 644 | + | assert!( | |
| 645 | + | splice_note(&engine, None, oid(absent), None) | |
| 646 | + | .unwrap() | |
| 647 | + | .is_none() | |
| 648 | + | ); | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | #[test] | |
| 652 | + | fn splicing_carries_unrelated_paths_through() { | |
| 653 | + | let (_tmp, repo) = init_bare(); | |
| 654 | + | let note = blob(&repo, "real note"); | |
| 655 | + | let stray = blob(&repo, "not a note"); | |
| 656 | + | ||
| 657 | + | // The same junk the reader tolerates: git does not promise a notes tree | |
| 658 | + | // holds only notes, so a splice that dropped what it did not recognize | |
| 659 | + | // would quietly delete somebody's file. | |
| 660 | + | let junk_dir = tree(&repo, &[("whatever", stray, GixEntryKind::Blob)]); | |
| 661 | + | let root = tree( | |
| 662 | + | &repo, | |
| 663 | + | &[ | |
| 664 | + | (T1, note, GixEntryKind::Blob), | |
| 665 | + | ("README", stray, GixEntryKind::Blob), | |
| 666 | + | ("zz", junk_dir, GixEntryKind::Tree), | |
| 667 | + | ], | |
| 668 | + | ); | |
| 669 | + | ||
| 670 | + | let engine = GixEngine::new(&repo); | |
| 671 | + | let added = splice_note( | |
| 672 | + | &engine, | |
| 673 | + | Some(ours(root)), | |
| 674 | + | oid(T3), | |
| 675 | + | Some(ours(blob(&repo, "another"))), | |
| 676 | + | ) | |
| 677 | + | .unwrap() | |
| 678 | + | .unwrap(); | |
| 679 | + | let removed = splice_note(&engine, Some(added), oid(T1), None) | |
| 680 | + | .unwrap() | |
| 681 | + | .unwrap(); | |
| 682 | + | ||
| 683 | + | let surviving = paths(&engine, removed); | |
| 684 | + | assert!(surviving.contains(&"README".to_string()), "{surviving:?}"); | |
| 685 | + | assert!( | |
| 686 | + | surviving.contains(&"zz/whatever".to_string()), | |
| 687 | + | "{surviving:?}" | |
| 688 | + | ); | |
| 689 | + | assert_eq!(flat(&engine, removed).len(), 1); | |
| 690 | + | } | |
| 691 | + | ||
| 692 | + | #[test] | |
| 693 | + | fn a_spliced_tree_reads_back_through_the_reader() { | |
| 694 | + | let (_tmp, repo) = init_bare(); | |
| 695 | + | let engine = GixEngine::new(&repo); | |
| 696 | + | ||
| 697 | + | // The end-to-end contract: whatever shape the writer chose, the reader | |
| 698 | + | // finds every note by target and nothing extra. Written one at a time so | |
| 699 | + | // the tree rebalances underneath, which is when a writer and reader that | |
| 700 | + | // disagree about fanout come apart. | |
| 701 | + | let mut tree: Option<Oid> = None; | |
| 702 | + | let mut expected = Vec::new(); | |
| 703 | + | for (target, text) in [(T1, "one"), (T2, "two"), (T3, "three")] { | |
| 704 | + | let content = ours(blob(&repo, text)); | |
| 705 | + | tree = splice_note(&engine, tree, oid(target), Some(content)).unwrap(); | |
| 706 | + | expected.push((oid(target), content)); | |
| 707 | + | } | |
| 708 | + | expected.sort_unstable(); | |
| 709 | + | ||
| 710 | + | let tree = tree.unwrap(); | |
| 711 | + | assert_eq!(flat(&engine, tree), expected); | |
| 712 | + | ||
| 713 | + | let commit = engine | |
| 714 | + | .write_commit( | |
| 715 | + | tree, | |
| 716 | + | &[], | |
| 717 | + | &signature("Max"), | |
| 718 | + | &signature("Max"), | |
| 719 | + | "notes: fixture\n", | |
| 720 | + | ) | |
| 721 | + | .unwrap(); | |
| 722 | + | engine | |
| 723 | + | .update_ref_cas("refs/notes/commits", None, commit) | |
| 724 | + | .unwrap(); | |
| 725 | + | ||
| 726 | + | let ns = namespace(&engine, DEFAULT_NAMESPACE); | |
| 727 | + | for (target, text) in [(T1, "one"), (T2, "two"), (T3, "three")] { | |
| 728 | + | let note = note_for(&engine, ns.tip, oid(target)).unwrap().unwrap(); | |
| 729 | + | assert_eq!(note.content_lossy(), text); | |
| 730 | + | } | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | // ── The engine's write half ── | |
| 734 | + | ||
| 735 | + | #[test] | |
| 736 | + | fn a_written_commit_carries_its_parent_and_signatures() { | |
| 737 | + | let (_tmp, repo) = init_bare(); | |
| 738 | + | let engine = GixEngine::new(&repo); | |
| 739 | + | let empty = engine.write_tree(&[]).unwrap(); | |
| 740 | + | ||
| 741 | + | let first = engine | |
| 742 | + | .write_commit(empty, &[], &signature("Author"), &signature("Max"), "one\n") | |
| 743 | + | .unwrap(); | |
| 744 | + | let second = engine | |
| 745 | + | .write_commit( | |
| 746 | + | empty, | |
| 747 | + | &[first], | |
| 748 | + | &signature("Author"), | |
| 749 | + | &signature("Max"), | |
| 750 | + | "two\n", | |
| 751 | + | ) | |
| 752 | + | .unwrap(); | |
| 753 | + | ||
| 754 | + | let meta = engine.read_commit(second).unwrap(); | |
| 755 | + | assert_eq!(meta.parents, vec![first]); | |
| 756 | + | assert_eq!(meta.tree, empty); | |
| 757 | + | assert_eq!(meta.committer.name, "Max"); | |
| 758 | + | assert_eq!(meta.committer.email, "Max@users.makenot.work"); | |
| 759 | + | assert_eq!(meta.author.name, "Author"); | |
| 760 | + | assert_eq!(meta.message, "two\n"); | |
| 761 | + | } | |
| 762 | + | ||
| 763 | + | #[test] | |
| 764 | + | fn cas_creates_a_ref_only_when_it_does_not_exist() { | |
| 765 | + | let (_tmp, repo) = init_bare(); | |
| 766 | + | let engine = GixEngine::new(&repo); | |
| 767 | + | let empty = engine.write_tree(&[]).unwrap(); | |
| 768 | + | let who = signature("Max"); | |
| 769 | + | let first = engine | |
| 770 | + | .write_commit(empty, &[], &who, &who, "one\n") | |
| 771 | + | .unwrap(); | |
| 772 | + | let second = engine | |
| 773 | + | .write_commit(empty, &[first], &who, &who, "two\n") | |
| 774 | + | .unwrap(); | |
| 775 | + | ||
| 776 | + | engine | |
| 777 | + | .update_ref_cas("refs/notes/commits", None, first) | |
| 778 | + | .unwrap(); | |
| 779 | + | assert_eq!( | |
| 780 | + | engine.resolve_ref("refs/notes/commits").unwrap(), | |
| 781 | + | Some(first) | |
| 782 | + | ); | |
| 783 | + | ||
| 784 | + | // Creating a ref that now exists is the same lost race as any other: the | |
| 785 | + | // first writer to a fresh namespace wins and the second retries. | |
| 786 | + | let raced = engine.update_ref_cas("refs/notes/commits", None, second); | |
| 787 | + | assert!(matches!(raced, Err(NotesError::Raced)), "{raced:?}"); | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | #[test] | |
| 791 | + | fn cas_rejects_an_update_from_a_stale_tip() { | |
| 792 | + | let (_tmp, repo) = init_bare(); | |
| 793 | + | let engine = GixEngine::new(&repo); | |
| 794 | + | let empty = engine.write_tree(&[]).unwrap(); | |
| 795 | + | let who = signature("Max"); | |
| 796 | + | let first = engine | |
| 797 | + | .write_commit(empty, &[], &who, &who, "one\n") | |
| 798 | + | .unwrap(); | |
| 799 | + | let second = engine | |
| 800 | + | .write_commit(empty, &[first], &who, &who, "two\n") | |
| 801 | + | .unwrap(); | |
| 802 | + | let third = engine | |
| 803 | + | .write_commit(empty, &[first], &who, &who, "three\n") | |
| 804 | + | .unwrap(); | |
| 805 | + | ||
| 806 | + | engine | |
| 807 | + | .update_ref_cas("refs/notes/commits", None, first) | |
| 808 | + | .unwrap(); | |
| 809 | + | engine | |
| 810 | + | .update_ref_cas("refs/notes/commits", Some(first), second) | |
| 811 | + | .unwrap(); | |
| 812 | + | ||
| 813 | + | // `third` was built on `first`, which is no longer the tip. Accepting it | |
| 814 | + | // would silently drop whatever `second` added. | |
| 815 | + | let raced = engine.update_ref_cas("refs/notes/commits", Some(first), third); | |
| 816 | + | assert!(matches!(raced, Err(NotesError::Raced)), "{raced:?}"); | |
| 817 | + | assert_eq!( | |
| 818 | + | engine.resolve_ref("refs/notes/commits").unwrap(), | |
| 819 | + | Some(second), | |
| 820 | + | "a lost race must not move the ref" | |
| 821 | + | ); | |
| 822 | + | } | |
| 823 | + | ||
| 416 | 824 | #[test] | |
| 417 | 825 | fn cache_agrees_with_an_uncached_walk() { | |
| 418 | 826 | let (_tmp, repo) = mixed_fanout_repo(); |