diff --git a/Cargo.lock b/Cargo.lock index 006f953a..54520952 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1924,6 +1924,7 @@ dependencies = [ "git2", "log", "nonempty 0.8.1", + "once_cell", "qcheck", "qcheck-macros", "radicle-crypto", diff --git a/radicle-cli/src/commands/comment.rs b/radicle-cli/src/commands/comment.rs index 0325f199..72e110f1 100644 --- a/radicle-cli/src/commands/comment.rs +++ b/radicle-cli/src/commands/comment.rs @@ -110,7 +110,7 @@ fn comment( let (comment_id, _) = issue.comments().next().expect("root comment always exists"); *comment_id }); - let comment_id = issue.comment(message, comment_id, &signer)?; + let comment_id = issue.comment(message, comment_id, vec![], &signer)?; term::print(comment_id); return Ok(()); diff --git a/radicle-cli/src/commands/issue.rs b/radicle-cli/src/commands/issue.rs index 622d01e4..c0847433 100644 --- a/radicle-cli/src/commands/issue.rs +++ b/radicle-cli/src/commands/issue.rs @@ -295,7 +295,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { description: Some(description), labels, } => { - let issue = issues.create(title, description, labels.as_slice(), &[], &signer)?; + let issue = issues.create(title, description, labels.as_slice(), &[], [], &signer)?; if !options.quiet { show_issue(&issue, issue.id())?; } @@ -569,6 +569,7 @@ fn open( description.trim(), meta.labels.as_slice(), meta.assignees.as_slice(), + [], signer, )?; if !options.quiet { @@ -598,7 +599,7 @@ fn edit( tx.edit(t)?; } if let Some(d) = description { - tx.edit_comment(desc_id, d)?; + tx.edit_comment(desc_id, d, vec![])?; } Ok(()) @@ -621,7 +622,7 @@ fn edit( issue.transaction("Edit", signer, |tx| { tx.edit(edited.title)?; - tx.edit_comment(desc_id, description)?; + tx.edit_comment(desc_id, description, vec![])?; tx.label(edited.labels)?; tx.assign(edited.assignees)?; diff --git a/radicle-cli/tests/commands.rs b/radicle-cli/tests/commands.rs index 1d32f117..deb71a36 100644 --- a/radicle-cli/tests/commands.rs +++ b/radicle-cli/tests/commands.rs @@ -794,6 +794,7 @@ fn test_cob_replication() { "I don't know what it is", &[], &[], + [], &bob.signer, ) .unwrap(); @@ -843,6 +844,7 @@ fn test_cob_deletion() { "I don't know what it is", &[], &[], + [], &alice.signer, ) .unwrap(); diff --git a/radicle-cob/Cargo.toml b/radicle-cob/Cargo.toml index aa6389c2..ea716e77 100644 --- a/radicle-cob/Cargo.toml +++ b/radicle-cob/Cargo.toml @@ -16,6 +16,7 @@ keywords = ["radicle", "cob", "cobs"] fastrand = { version = "1.9.0" } log = { version = "0.4.17" } nonempty = { version = "0.8.1", features = ["serialize"] } +once_cell = { version = "1.13" } radicle-git-ext = { version = "0.6.0", features = ["serde"] } serde_json = { version = "1.0" } thiserror = { version = "1.0" } diff --git a/radicle-cob/src/backend/git/change.rs b/radicle-cob/src/backend/git/change.rs index 469e78fc..a6488e0e 100644 --- a/radicle-cob/src/backend/git/change.rs +++ b/radicle-cob/src/backend/git/change.rs @@ -2,11 +2,13 @@ use std::collections::BTreeMap; use std::convert::TryFrom; +use std::path::PathBuf; use git_ext::author::Author; use git_ext::commit::{headers::Headers, Commit}; use git_ext::Oid; use nonempty::NonEmpty; +use once_cell::sync::Lazy; use radicle_git_ext::commit::trailers::OwnedTrailer; use crate::change::store::Version; @@ -16,10 +18,13 @@ use crate::{ change::{self, store, Change}, history::entry, signatures::{ExtendedSignature, Signatures}, - trailers, + trailers, Embed, }; -const MANIFEST_BLOB_NAME: &str = "manifest"; +/// Name of the COB manifest file. +pub const MANIFEST_BLOB_NAME: &str = "manifest"; +/// Path under which COB embeds are kept. +pub static EMBEDS_PATH: Lazy = Lazy::new(|| PathBuf::from("embeds")); pub mod error { use std::str::Utf8Error; @@ -102,10 +107,11 @@ impl change::Storage for git2::Repository { type_name, tips, message, + embeds, contents, } = spec; let manifest = store::Manifest::new(type_name, Version::default()); - let revision = write_manifest(self, &manifest, &contents)?; + let revision = write_manifest(self, &manifest, embeds, &contents)?; let tree = self.find_tree(revision)?; let signature = { let sig = signer.sign(revision.as_bytes()); @@ -281,6 +287,8 @@ where #[cfg(debug_assertions)] let (author, timestamp) = if let Ok(s) = std::env::var(crate::git::RAD_COMMIT_TIME) { + // SAFETY: It's ok to panic here, since this is only enabled in debug mode. + #[allow(clippy::unwrap_used)] let timestamp = s.trim().parse::().unwrap(); let author = Author { time: git_ext::author::Time::new(timestamp, 0), @@ -308,24 +316,47 @@ where fn write_manifest( repo: &git2::Repository, manifest: &store::Manifest, + embeds: Vec, contents: &NonEmpty>, ) -> Result { - let mut tb = repo.treebuilder(None)?; - // SAFETY: we're serializing to an in memory buffer so the only source of - // errors here is a programming error, which we can't recover from - let serialized_manifest = serde_json::to_vec(manifest).unwrap(); - let manifest_oid = repo.blob(&serialized_manifest)?; - tb.insert( - MANIFEST_BLOB_NAME, - manifest_oid, - git2::FileMode::Blob.into(), - )?; + let mut root = repo.treebuilder(None)?; + // Insert manifest file into tree. + { + // SAFETY: we're serializing to an in memory buffer so the only source of + // errors here is a programming error, which we can't recover from. + #[allow(clippy::unwrap_used)] + let manifest = serde_json::to_vec(manifest).unwrap(); + let manifest_oid = repo.blob(&manifest)?; + + root.insert( + MANIFEST_BLOB_NAME, + manifest_oid, + git2::FileMode::Blob.into(), + )?; + } + + // Insert each COB entry. for (ix, op) in contents.iter().enumerate() { let oid = repo.blob(op.as_ref())?; - tb.insert(&ix.to_string(), oid, git2::FileMode::Blob.into())?; + root.insert(&ix.to_string(), oid, git2::FileMode::Blob.into())?; } - let tree_oid = tb.write()?; - Ok(tree_oid) + // Insert each embed in a tree at `/embeds`. + if !embeds.is_empty() { + let mut embeds_tree = repo.treebuilder(None)?; + + for embed in embeds { + let oid = repo.blob(&embed.content)?; + let path = PathBuf::from(embed.name); + + embeds_tree.insert(path, oid, git2::FileMode::Blob.into())?; + } + let oid = embeds_tree.write()?; + + root.insert(&*EMBEDS_PATH, oid, git2::FileMode::Tree.into())?; + } + let oid = root.write()?; + + Ok(oid) } diff --git a/radicle-cob/src/change/store.rs b/radicle-cob/src/change/store.rs index 2af6fc4a..663898fe 100644 --- a/radicle-cob/src/change/store.rs +++ b/radicle-cob/src/change/store.rs @@ -48,6 +48,7 @@ pub struct Template { pub type_name: TypeName, pub tips: Vec, pub message: String, + pub embeds: Vec, pub contents: NonEmpty>, } @@ -179,3 +180,38 @@ impl Version { NonZeroUsize::new(version).map(Self) } } + +/// Embedded object. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Embed> { + /// File name. + pub name: String, + /// File content or content hash. + pub content: T, +} + +impl Embed> { + /// Get the object id of the embedded content. + pub fn oid(&self) -> Oid { + // SAFETY: This should not fail since we are using a valid object type. + git2::Oid::hash_object(git2::ObjectType::Blob, &self.content) + .expect("Embed::oid: invalid object") + .into() + } + + /// Return am embed where the content is replaced by a content hash. + pub fn hashed>(&self) -> Embed { + Embed { + name: self.name.clone(), + content: T::from(self.oid()), + } + } +} + +impl Embed { + /// Get the object id of the embedded content. + pub fn oid(&self) -> Oid { + self.content + } +} diff --git a/radicle-cob/src/change_graph.rs b/radicle-cob/src/change_graph.rs index 41ebba25..8bae80ff 100644 --- a/radicle-cob/src/change_graph.rs +++ b/radicle-cob/src/change_graph.rs @@ -1,7 +1,7 @@ // Copyright © 2021 The Radicle Link Contributors +use std::collections::BTreeSet; use std::ops::ControlFlow; -use std::{collections::BTreeSet, convert::TryInto}; use git_ext::Oid; use radicle_dag::Dag; @@ -135,8 +135,8 @@ impl ChangeGraph { self.graph.tips().map(|(_, change)| *change.id()).collect() } - pub(crate) fn number_of_nodes(&self) -> u64 { - self.graph.len().try_into().unwrap() + pub(crate) fn number_of_nodes(&self) -> usize { + self.graph.len() } } diff --git a/radicle-cob/src/lib.rs b/radicle-cob/src/lib.rs index 979fb7e1..61d683ac 100644 --- a/radicle-cob/src/lib.rs +++ b/radicle-cob/src/lib.rs @@ -1,5 +1,6 @@ // Copyright © 2021 The Radicle Link Contributors +#![warn(clippy::unwrap_used)] //! # Collaborative Objects //! //! Collaborative objects are graphs of CRDTs. The current CRDTs that @@ -80,7 +81,7 @@ mod change_graph; mod trailers; pub mod change; -pub use change::store::{Manifest, Version}; +pub use change::store::{Embed, Manifest, Version}; pub use change::Change; pub mod history; @@ -98,9 +99,11 @@ pub use object::{ }; #[cfg(test)] +#[allow(clippy::unwrap_used)] mod test; #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests; /// The `Store` is an aggregation of the different types of storage diff --git a/radicle-cob/src/object/collaboration/create.rs b/radicle-cob/src/object/collaboration/create.rs index 661d10e5..ac0a6a5d 100644 --- a/radicle-cob/src/object/collaboration/create.rs +++ b/radicle-cob/src/object/collaboration/create.rs @@ -2,6 +2,7 @@ use nonempty::NonEmpty; +use crate::Embed; use crate::Store; use super::*; @@ -14,17 +15,20 @@ pub struct Create { pub type_name: TypeName, /// The message to add when creating this object. pub message: String, + /// Embedded content. + pub embeds: Vec, /// COB version. pub version: Version, } impl Create { - fn template(&self) -> change::Template { + fn template(self) -> change::Template { change::Template { - type_name: self.type_name.clone(), + type_name: self.type_name, tips: Vec::new(), - message: self.message.clone(), - contents: self.contents.clone(), + message: self.message, + embeds: self.embeds, + contents: self.contents, } } } @@ -59,14 +63,15 @@ where S: Store, G: crypto::Signer, { - let Create { type_name, .. } = &args; + let type_name = args.type_name.clone(); + let version = args.version; let init_change = storage .store(resource, parents, signer, args.template()) .map_err(error::Create::from)?; let object_id = init_change.id().into(); storage - .update(identifier, type_name, &object_id, &init_change) + .update(identifier, &type_name, &object_id, &init_change) .map_err(|err| error::Create::Refs { err: Box::new(err) })?; let history = History::new_from_root( @@ -79,7 +84,7 @@ where ); Ok(CollaborativeObject { - manifest: Manifest::new(args.type_name, args.version), + manifest: Manifest::new(type_name, version), history, id: object_id, }) diff --git a/radicle-cob/src/object/collaboration/info.rs b/radicle-cob/src/object/collaboration/info.rs index 211196a3..2172efa8 100644 --- a/radicle-cob/src/object/collaboration/info.rs +++ b/radicle-cob/src/object/collaboration/info.rs @@ -17,7 +17,7 @@ pub struct ChangeGraphInfo { /// The ID of the object pub object_id: ObjectId, /// The number of nodes in the change graph of the object - pub number_of_nodes: u64, + pub number_of_nodes: usize, /// The "tips" of the change graph, i.e the object IDs pointed to by /// references to the object pub tips: BTreeSet, diff --git a/radicle-cob/src/object/collaboration/update.rs b/radicle-cob/src/object/collaboration/update.rs index 48fef860..294f004f 100644 --- a/radicle-cob/src/object/collaboration/update.rs +++ b/radicle-cob/src/object/collaboration/update.rs @@ -4,8 +4,8 @@ use git_ext::Oid; use nonempty::NonEmpty; use crate::{ - change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, ObjectId, Store, - TypeName, + change, change_graph::ChangeGraph, history::EntryId, CollaborativeObject, Embed, ObjectId, + Store, TypeName, }; use super::error; @@ -31,6 +31,8 @@ pub struct Update { pub type_name: TypeName, /// The message to add when updating this object. pub message: String, + /// Embedded files. + pub embeds: Vec, } /// Update an existing [`CollaborativeObject`]. @@ -67,6 +69,7 @@ where let Update { type_name: ref typename, object_id, + embeds, changes, message, } = args; @@ -85,6 +88,7 @@ where signer, change::Template { tips: object.tips().iter().cloned().collect(), + embeds, contents: changes, type_name: typename.clone(), message, diff --git a/radicle-cob/src/tests.rs b/radicle-cob/src/tests.rs index 9ee56dfa..68506e7b 100644 --- a/radicle-cob/src/tests.rs +++ b/radicle-cob/src/tests.rs @@ -34,6 +34,7 @@ fn roundtrip() { contents: nonempty!(Vec::new()), type_name: typename.clone(), message: "creating xyz.rad.issue".to_string(), + embeds: vec![], version: Version::default(), }, ) @@ -67,6 +68,7 @@ fn list_cobs() { contents: nonempty!(b"issue 1".to_vec()), type_name: typename.clone(), message: "creating xyz.rad.issue".to_string(), + embeds: vec![], version: Version::default(), }, ) @@ -82,6 +84,7 @@ fn list_cobs() { contents: nonempty!(b"issue 2".to_vec()), type_name: typename.clone(), message: "commenting xyz.rad.issue".to_string(), + embeds: vec![], version: Version::default(), }, ) @@ -117,6 +120,7 @@ fn update_cob() { contents: nonempty!(Vec::new()), type_name: typename.clone(), message: "creating xyz.rad.issue".to_string(), + embeds: vec![], version: Version::default(), }, ) @@ -136,6 +140,7 @@ fn update_cob() { changes: nonempty!(b"issue 1".to_vec()), object_id: *cob.id(), type_name: typename.clone(), + embeds: vec![], message: "commenting xyz.rad.issue".to_string(), }, ) @@ -176,6 +181,7 @@ fn traverse_cobs() { contents: nonempty!(b"issue 1".to_vec()), type_name: typename.clone(), message: "creating xyz.rad.issue".to_string(), + embeds: vec![], version: Version::default(), }, ) @@ -199,6 +205,7 @@ fn traverse_cobs() { changes: nonempty!(b"issue 2".to_vec()), object_id: *cob.id(), type_name: typename, + embeds: vec![], message: "commenting on xyz.rad.issue".to_string(), }, ) diff --git a/radicle-cob/src/trailers.rs b/radicle-cob/src/trailers.rs index 8ea84b3f..7374e764 100644 --- a/radicle-cob/src/trailers.rs +++ b/radicle-cob/src/trailers.rs @@ -58,6 +58,8 @@ impl From for ResourceCommitTrailer { impl From for Trailer<'_> { fn from(containing: ResourceCommitTrailer) -> Self { Trailer { + // SAFETY: "Rad-Resource" is a valid `Token`. + #[allow(clippy::unwrap_used)] token: Token::try_from("Rad-Resource").unwrap(), value: containing.0.to_string().into(), } diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs index 9b930e72..d9e235fc 100644 --- a/radicle-httpd/src/api/v1/projects.rs +++ b/radicle-httpd/src/api/v1/projects.rs @@ -507,6 +507,7 @@ async fn issue_create_handler( issue.description, &issue.labels, &issue.assignees, + [], &signer, ) .map_err(Error::from)?; @@ -546,9 +547,9 @@ async fn issue_update_handler( issue::Action::Edit { title } => { issue.edit(title, &signer)?; } - issue::Action::Comment { body, reply_to } => { + issue::Action::Comment { body, reply_to, .. } => { if let Some(to) = reply_to { - issue.comment(body, to, &signer)?; + issue.comment(body, to, [], &signer)?; } else { return Err(Error::BadRequest("`replyTo` missing".to_owned())); } diff --git a/radicle-httpd/src/test.rs b/radicle-httpd/src/test.rs index 641096e2..08e52112 100644 --- a/radicle-httpd/src/test.rs +++ b/radicle-httpd/src/test.rs @@ -185,6 +185,7 @@ fn seed_with_signer(dir: &Path, profile: radicle::Profile, signer: &G "Change 'hello world' to 'hello everyone'".to_string(), &[], &[], + [], signer, ) .unwrap(); diff --git a/radicle-node/src/test/environment.rs b/radicle-node/src/test/environment.rs index 98d8802d..fc264d62 100644 --- a/radicle-node/src/test/environment.rs +++ b/radicle-node/src/test/environment.rs @@ -315,7 +315,7 @@ impl NodeHandle { let repo = self.storage.repository(rid).unwrap(); let mut issues = issue::Issues::open(&repo).unwrap(); *issues - .create(title, desc, &[], &[], &self.signer) + .create(title, desc, &[], &[], [], &self.signer) .unwrap() .id() } diff --git a/radicle/src/cob.rs b/radicle/src/cob.rs index 7eb06342..74847554 100644 --- a/radicle/src/cob.rs +++ b/radicle/src/cob.rs @@ -12,7 +12,7 @@ pub mod test; pub use cob::{ change, history::EntryId, object, object::collaboration::error, CollaborativeObject, Contents, - Create, Entry, History, Manifest, ObjectId, Store, TypeName, Update, Updated, Version, + Create, Embed, Entry, History, Manifest, ObjectId, Store, TypeName, Update, Updated, Version, }; pub use cob::{create, get, list, remove, update}; pub use common::*; diff --git a/radicle/src/cob/common.rs b/radicle/src/cob/common.rs index de9c5812..e2b25d23 100644 --- a/radicle/src/cob/common.rs +++ b/radicle/src/cob/common.rs @@ -4,6 +4,7 @@ use std::str::FromStr; use localtime::LocalTime; use serde::{Deserialize, Serialize}; +use crate::git_ext::Oid; use crate::prelude::*; /// Timestamp used for COB operations. @@ -226,6 +227,57 @@ impl<'a> Deserialize<'a> for Color { } } +/// A URI. +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Uri(String); + +impl Uri { + /// Get a string reference to the URI. + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl From for Uri { + fn from(oid: Oid) -> Self { + Uri(format!("git:{oid}")) + } +} + +impl TryFrom<&Uri> for Oid { + type Error = Uri; + + fn try_from(value: &Uri) -> Result { + if let Some(oid) = value.as_str().strip_prefix("git:") { + let oid = oid.parse().map_err(|_| value.clone())?; + + return Ok(oid); + } + Err(value.clone()) + } +} + +impl std::fmt::Display for Uri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::str::FromStr for Uri { + type Err = String; + + fn from_str(s: &str) -> Result { + if !s.chars().all(|c| c.is_ascii()) { + return Err(s.to_owned()); + } + if !s.contains(':') { + return Err(s.to_owned()); + } + Ok(Self(s.to_owned())) + } +} + #[cfg(test)] mod test { use super::*; diff --git a/radicle/src/cob/identity.rs b/radicle/src/cob/identity.rs index 48018d00..06202685 100644 --- a/radicle/src/cob/identity.rs +++ b/radicle/src/cob/identity.rs @@ -320,7 +320,7 @@ impl store::FromHistory for Proposal { type Error = ApplyError; fn type_name() -> &'static TypeName { - &*TYPENAME + &TYPENAME } fn validate(&self) -> Result<(), Self::Error> { @@ -390,6 +390,7 @@ impl store::FromHistory for Proposal { body, reply_to, None, + vec![], )?; } } @@ -399,7 +400,14 @@ impl store::FromHistory for Proposal { body, } => { if let Some(revision) = lookup::revision(self, &revision)? { - thread::edit(&mut revision.discussion, op.id, comment, op.timestamp, body)?; + thread::edit( + &mut revision.discussion, + op.id, + comment, + op.timestamp, + body, + vec![], + )?; } } Action::RevisionCommentRedact { revision, comment } => { diff --git a/radicle/src/cob/issue.rs b/radicle/src/cob/issue.rs index f2784590..8900bdba 100644 --- a/radicle/src/cob/issue.rs +++ b/radicle/src/cob/issue.rs @@ -7,12 +7,12 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::cob; -use crate::cob::common::{Author, Label, Reaction, Timestamp}; +use crate::cob::common::{Author, Label, Reaction, Timestamp, Uri}; use crate::cob::store::Transaction; use crate::cob::store::{FromHistory as _, HistoryAction}; use crate::cob::thread; use crate::cob::thread::{CommentId, Thread}; -use crate::cob::{store, EntryId, ObjectId, TypeName}; +use crate::cob::{store, Embed, EntryId, ObjectId, TypeName}; use crate::crypto::Signer; use crate::prelude::{Did, ReadRepository}; use crate::storage::WriteRepository; @@ -109,7 +109,7 @@ impl store::FromHistory for Issue { type Error = Error; fn type_name() -> &'static TypeName { - &*TYPENAME + &TYPENAME } fn validate(&self) -> Result<(), Self::Error> { @@ -154,7 +154,11 @@ impl store::FromHistory for Issue { Action::Label { labels } => { self.labels = BTreeSet::from_iter(labels); } - Action::Comment { body, reply_to } => { + Action::Comment { + body, + reply_to, + embeds, + } => { thread::comment( &mut self.thread, op.id, @@ -163,10 +167,11 @@ impl store::FromHistory for Issue { body, reply_to, None, + embeds, )?; } - Action::CommentEdit { id, body } => { - thread::edit(&mut self.thread, op.id, id, op.timestamp, body)?; + Action::CommentEdit { id, body, embeds } => { + thread::edit(&mut self.thread, op.id, id, op.timestamp, body, embeds)?; } Action::CommentRedact { id } => { thread::redact(&mut self.thread, op.id, id)?; @@ -252,10 +257,19 @@ impl store::Transaction { } /// Edit an issue comment. - pub fn edit_comment(&mut self, id: CommentId, body: impl ToString) -> Result<(), store::Error> { + pub fn edit_comment( + &mut self, + id: CommentId, + body: impl ToString, + embeds: Vec, + ) -> Result<(), store::Error> { + let hashed = embeds.iter().map(|e| e.hashed()).collect(); + + self.embed(embeds)?; self.push(Action::CommentEdit { id, body: body.to_string(), + embeds: hashed, }) } @@ -276,10 +290,15 @@ impl store::Transaction { &mut self, body: S, reply_to: CommentId, + embeds: Vec, ) -> Result<(), store::Error> { + let hashed = embeds.iter().map(|e| e.hashed()).collect(); + + self.embed(embeds)?; self.push(Action::Comment { body: body.to_string(), reply_to: Some(reply_to), + embeds: hashed, }) } @@ -307,10 +326,19 @@ impl store::Transaction { //////////////////////////////////////////////////////////////////////////////////////////////// /// Create the issue thread. - fn thread(&mut self, body: S) -> Result<(), store::Error> { + fn thread( + &mut self, + body: S, + embeds: impl IntoIterator, + ) -> Result<(), store::Error> { + let embeds = embeds.into_iter().collect::>(); + let hashed = embeds.iter().map(|e| e.hashed()).collect(); + + self.embed(embeds)?; self.push(Action::Comment { body: body.to_string(), reply_to: None, + embeds: hashed, }) } } @@ -367,6 +395,7 @@ where pub fn edit_description( &mut self, description: impl ToString, + embeds: impl IntoIterator, signer: &G, ) -> Result { let Some((id, _)) = self.thread.comments().next() else { @@ -374,7 +403,7 @@ where }; let id = *id; self.transaction("Edit description", signer, |tx| { - tx.edit_comment(id, description) + tx.edit_comment(id, description, embeds.into_iter().collect()) }) } @@ -388,13 +417,16 @@ where &mut self, body: S, reply_to: CommentId, + embeds: impl IntoIterator, signer: &G, ) -> Result { assert!( self.thread.comment(&reply_to).is_some(), "Comment {reply_to} not found" ); - self.transaction("Comment", signer, |tx| tx.comment(body, reply_to)) + self.transaction("Comment", signer, |tx| { + tx.comment(body, reply_to, embeds.into_iter().collect()) + }) } /// Label an issue. @@ -502,10 +534,11 @@ where description: impl ToString, labels: &[Label], assignees: &[Did], + embeds: impl IntoIterator, signer: &G, ) -> Result, Error> { let (id, issue) = Transaction::initial("Create issue", &mut self.raw, signer, |tx| { - tx.thread(description)?; + tx.thread(description, embeds)?; tx.assign(assignees.to_owned())?; tx.edit(title)?; tx.label(labels.to_owned())?; @@ -573,11 +606,21 @@ pub enum Action { /// Should be the root [`CommentId`] if it's a top-level comment. #[serde(default, skip_serializing_if = "Option::is_none")] reply_to: Option, + /// Embeded content. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + embeds: Vec>, }, /// Edit a comment. #[serde(rename = "comment.edit")] - CommentEdit { id: CommentId, body: String }, + CommentEdit { + /// Comment being edited. + id: CommentId, + /// New value for the comment body. + body: String, + /// New value for the embeds list. + embeds: Vec>, + }, /// Redact a change. Not all changes can be redacted. #[serde(rename = "comment.redact")] @@ -600,6 +643,7 @@ mod test { use super::*; use crate::cob::{ActorId, Reaction}; + use crate::git::Oid; use crate::test; use crate::test::arbitrary; @@ -611,7 +655,14 @@ mod test { let mut eve_issues = Issues::open(&*t.eve.repo).unwrap(); let mut issue_alice = issues_alice - .create("Alice Issue", "Alice's comment", &[], &[], &t.alice.signer) + .create( + "Alice Issue", + "Alice's comment", + &[], + &[], + [], + &t.alice.signer, + ) .unwrap(); let id = *issue_alice.id(); @@ -622,10 +673,10 @@ mod test { let mut issue_bob = bob_issues.get_mut(&id).unwrap(); issue_bob - .comment("Bob's reply", id.into(), &t.bob.signer) + .comment("Bob's reply", id.into(), vec![], &t.bob.signer) .unwrap(); issue_alice - .comment("Alice's reply", id.into(), &t.alice.signer) + .comment("Alice's reply", id.into(), vec![], &t.alice.signer) .unwrap(); assert_eq!(issue_bob.comments().count(), 2); @@ -653,7 +704,7 @@ mod test { t.eve.repo.fetch(&t.alice); let eve_reply = issue_eve - .comment("Eve's reply", id.into(), &t.eve.signer) + .comment("Eve's reply", id.into(), vec![], &t.eve.signer) .unwrap(); t.bob.repo.fetch(&t.eve); @@ -698,6 +749,7 @@ mod test { "Blah blah blah.", &[], &[assignee], + [], &node.signer, ) .unwrap(); @@ -736,6 +788,7 @@ mod test { "Blah blah blah.", &[], &[assignee], + [], &node.signer, ) .unwrap(); @@ -760,7 +813,14 @@ mod test { let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let mut issues = Issues::open(&*repo).unwrap(); let created = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &node.signer, + ) .unwrap(); let (id, created) = (created.id, created.issue); @@ -779,7 +839,14 @@ mod test { let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let mut issues = Issues::open(&*repo).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &node.signer, + ) .unwrap(); issue @@ -820,6 +887,7 @@ mod test { "Blah blah blah.", &[], &[assignee, assignee_two], + [], &node.signer, ) .unwrap(); @@ -839,7 +907,14 @@ mod test { let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let mut issues = Issues::open(&*repo).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &node.signer, + ) .unwrap(); issue.edit("Sorry typo", &node.signer).unwrap(); @@ -856,11 +931,18 @@ mod test { let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let mut issues = Issues::open(&*repo).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &node.signer, + ) .unwrap(); issue - .edit_description("Bob Loblaw law blog", &node.signer) + .edit_description("Bob Loblaw law blog", vec![], &node.signer) .unwrap(); let id = issue.id; @@ -875,7 +957,14 @@ mod test { let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let mut issues = Issues::open(&*repo).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &node.signer, + ) .unwrap(); let (comment, _) = issue.root(); @@ -897,13 +986,24 @@ mod test { let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); let mut issues = Issues::open(&*repo).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &node.signer, + ) .unwrap(); let (root, _) = issue.root(); let root = *root; - let c1 = issue.comment("Hi hi hi.", root, &node.signer).unwrap(); - let c2 = issue.comment("Ha ha ha.", root, &node.signer).unwrap(); + let c1 = issue + .comment("Hi hi hi.", root, vec![], &node.signer) + .unwrap(); + let c2 = issue + .comment("Ha ha ha.", root, vec![], &node.signer) + .unwrap(); let id = issue.id; let mut issue = issues.get_mut(&id).unwrap(); @@ -913,10 +1013,14 @@ mod test { assert_eq!(reply1.body(), "Hi hi hi."); assert_eq!(reply2.body(), "Ha ha ha."); - issue.comment("Re: Hi.", c1, &node.signer).unwrap(); - issue.comment("Re: Ha.", c2, &node.signer).unwrap(); - issue.comment("Re: Ha. Ha.", c2, &node.signer).unwrap(); - issue.comment("Re: Ha. Ha. Ha.", c2, &node.signer).unwrap(); + issue.comment("Re: Hi.", c1, vec![], &node.signer).unwrap(); + issue.comment("Re: Ha.", c2, vec![], &node.signer).unwrap(); + issue + .comment("Re: Ha. Ha.", c2, vec![], &node.signer) + .unwrap(); + issue + .comment("Re: Ha. Ha. Ha.", c2, vec![], &node.signer) + .unwrap(); let issue = issues.get(&id).unwrap().unwrap(); @@ -942,6 +1046,7 @@ mod test { "Blah blah blah.", &[ux_label.clone()], &[], + [], &node.signer, ) .unwrap(); @@ -971,15 +1076,26 @@ mod test { let author = *node.signer.public_key(); let mut issues = Issues::open(&*repo).unwrap(); let mut issue = issues - .create("My first issue", "Blah blah blah.", &[], &[], &node.signer) + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [], + &node.signer, + ) .unwrap(); // The root thread op id is always the same. let (c0, _) = issue.root(); let c0 = *c0; - issue.comment("Ho ho ho.", c0, &node.signer).unwrap(); - issue.comment("Ha ha ha.", c0, &node.signer).unwrap(); + issue + .comment("Ho ho ho.", c0, vec![], &node.signer) + .unwrap(); + issue + .comment("Ha ha ha.", c0, vec![], &node.signer) + .unwrap(); let id = issue.id; let issue = issues.get(&id).unwrap().unwrap(); @@ -1017,13 +1133,13 @@ mod test { let mut issues = Issues::open(&*repo).unwrap(); issues - .create("First", "Blah", &[], &[], &node.signer) + .create("First", "Blah", &[], &[], [], &node.signer) .unwrap(); issues - .create("Second", "Blah", &[], &[], &node.signer) + .create("Second", "Blah", &[], &[], [], &node.signer) .unwrap(); issues - .create("Third", "Blah", &[], &[], &node.signer) + .create("Third", "Blah", &[], &[], [], &node.signer) .unwrap(); let issues = issues @@ -1050,6 +1166,7 @@ mod test { "Blah blah blah.\nYah yah yah", &[], &[], + [], &node.signer, ) .unwrap(); @@ -1064,4 +1181,106 @@ mod test { assert_eq!(issue.comments().count(), 1); assert_eq!(issue.state(), &State::Open); } + + #[test] + fn test_embeds() { + let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); + let mut issues = Issues::open(&*repo).unwrap(); + let embed1 = Embed { + name: String::from("example.html"), + content: b"Hello World!".to_vec(), + }; + let embed2 = Embed { + name: String::from("style.css"), + content: b"body { color: red }".to_vec(), + }; + let embed3 = Embed { + name: String::from("bin"), + content: vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }; + let mut issue = issues + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [embed1.clone(), embed2.clone()], + &node.signer, + ) + .unwrap(); + + issue + .comment( + "Here's a binary file", + issue.id.into(), + [embed3.clone()], + &node.signer, + ) + .unwrap(); + + issue.reload().unwrap(); + + let (_, c0) = issue.thread().comments().next().unwrap(); + let (_, c1) = issue.thread().comments().next_back().unwrap(); + + let e1 = &c0.embeds()[0]; + let e2 = &c0.embeds()[1]; + let e3 = &c1.embeds()[0]; + + let b1 = repo.blob(Oid::try_from(&e1.content).unwrap()).unwrap(); + let b2 = repo.blob(Oid::try_from(&e2.content).unwrap()).unwrap(); + let b3 = repo.blob(Oid::try_from(&e3.content).unwrap()).unwrap(); + + assert_eq!(b1.content(), &embed1.content); + assert_eq!(b2.content(), &embed2.content); + assert_eq!(b3.content(), &embed3.content); + + assert_eq!(b1.is_binary(), false); + assert_eq!(b2.is_binary(), false); + assert_eq!(b3.is_binary(), true); + } + + #[test] + fn test_embeds_edit() { + let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default(); + let mut issues = Issues::open(&*repo).unwrap(); + let embed1 = Embed { + name: String::from("example.html"), + content: b"Hello World!".to_vec(), + }; + let embed1_edited = Embed { + name: String::from("example.html"), + content: b"Hello Radicle!".to_vec(), + }; + let embed2 = Embed { + name: String::from("style.css"), + content: b"body { color: red }".to_vec(), + }; + let mut issue = issues + .create( + "My first issue", + "Blah blah blah.", + &[], + &[], + [embed1.clone(), embed2.clone()], + &node.signer, + ) + .unwrap(); + + issue.reload().unwrap(); + issue + .edit_description("My first issue", [embed1_edited.clone()], &node.signer) + .unwrap(); + issue.reload().unwrap(); + + let (_, c0) = issue.thread().comments().next().unwrap(); + + assert_eq!(c0.embeds().len(), 1); + + let e1 = &c0.embeds()[0]; + let b1 = repo.blob(Oid::try_from(&e1.content).unwrap()).unwrap(); + + assert_eq!(e1.content, Uri::from(embed1_edited.oid())); + assert_eq!(b1.content(), &embed1_edited.content); + } } diff --git a/radicle/src/cob/legacy/issue.rs b/radicle/src/cob/legacy/issue.rs index abdbb8ff..8358d204 100644 --- a/radicle/src/cob/legacy/issue.rs +++ b/radicle/src/cob/legacy/issue.rs @@ -28,7 +28,7 @@ impl store::FromHistory for Issue { type Error = Error; fn type_name() -> &'static TypeName { - &*issue::TYPENAME + &issue::TYPENAME } fn validate(&self) -> Result<(), Self::Error> { diff --git a/radicle/src/cob/legacy/patch.rs b/radicle/src/cob/legacy/patch.rs index 897298c6..fa885336 100644 --- a/radicle/src/cob/legacy/patch.rs +++ b/radicle/src/cob/legacy/patch.rs @@ -103,7 +103,7 @@ impl store::FromHistory for Patch { type Error = Error; fn type_name() -> &'static TypeName { - &*TYPENAME + &TYPENAME } fn validate(&self) -> Result<(), Self::Error> { @@ -242,6 +242,7 @@ impl store::FromHistory for Patch { comment, timestamp, body, + vec![], )?; } } @@ -277,6 +278,7 @@ impl store::FromHistory for Patch { body, None, Some(location), + vec![], )?; } } diff --git a/radicle/src/cob/patch.rs b/radicle/src/cob/patch.rs index fe022f2c..8abb5b7f 100644 --- a/radicle/src/cob/patch.rs +++ b/radicle/src/cob/patch.rs @@ -449,7 +449,7 @@ impl store::FromHistory for Patch { type Error = Error; fn type_name() -> &'static TypeName { - &*TYPENAME + &TYPENAME } fn validate(&self) -> Result<(), Self::Error> { @@ -626,7 +626,14 @@ impl store::FromHistory for Patch { body, } => { if let Some(review) = lookup::review(self, &review)? { - thread::edit(&mut review.comments, op.id, comment, timestamp, body)?; + thread::edit( + &mut review.comments, + op.id, + comment, + timestamp, + body, + vec![], + )?; } } Action::ReviewCommentResolve { .. } => { @@ -650,6 +657,7 @@ impl store::FromHistory for Patch { body, reply_to, location, + vec![], )?; } } @@ -739,6 +747,7 @@ impl store::FromHistory for Patch { body, reply_to, None, + vec![], )?; } } @@ -748,7 +757,14 @@ impl store::FromHistory for Patch { body, } => { if let Some(revision) = lookup::revision(self, &revision)? { - thread::edit(&mut revision.discussion, op.id, comment, op.timestamp, body)?; + thread::edit( + &mut revision.discussion, + op.id, + comment, + op.timestamp, + body, + vec![], + )?; } } Action::RevisionCommentRedact { revision, comment } => { diff --git a/radicle/src/cob/store.rs b/radicle/src/cob/store.rs index 2c36608d..5a593e64 100644 --- a/radicle/src/cob/store.rs +++ b/radicle/src/cob/store.rs @@ -10,7 +10,9 @@ use serde::{Deserialize, Serialize}; use crate::cob::common::Timestamp; use crate::cob::op::Op; -use crate::cob::{ActorId, Create, EntryId, History, ObjectId, TypeName, Update, Updated, Version}; +use crate::cob::{ + ActorId, Create, Embed, EntryId, History, ObjectId, TypeName, Update, Updated, Version, +}; use crate::git; use crate::prelude::*; use crate::storage::git as storage; @@ -165,6 +167,7 @@ where object_id: ObjectId, message: &str, actions: impl Into>, + embeds: Vec, signer: &G, ) -> Result { let actions = actions.into(); @@ -180,6 +183,7 @@ where object_id, type_name: T::type_name().clone(), message: message.to_owned(), + embeds, changes, }, )?; @@ -194,6 +198,7 @@ where &self, message: &str, actions: impl Into>, + embeds: Vec, signer: &G, ) -> Result<(ObjectId, T), Error> { let actions = actions.into(); @@ -209,6 +214,7 @@ where type_name: T::type_name().clone(), version: Version::default(), message: message.to_owned(), + embeds, contents, }, )?; @@ -288,6 +294,7 @@ where pub struct Transaction { actor: ActorId, actions: Vec, + embeds: Vec, } impl Transaction { @@ -296,6 +303,7 @@ impl Transaction { Self { actor, actions: Vec::new(), + embeds: Vec::new(), } } @@ -313,15 +321,13 @@ impl Transaction { T::Action: Serialize + Clone, { let actor = *signer.public_key(); - let mut tx = Transaction { - actor, - actions: Vec::new(), - }; + let mut tx = Transaction::new(actor); + operations(&mut tx)?; let actions = NonEmpty::from_vec(tx.actions) .expect("Transaction::initial: transaction must contain at least one operation"); - let (id, cob) = store.create(message, actions, signer)?; + let (id, cob) = store.create(message, actions, tx.embeds, signer)?; Ok((id, cob)) } @@ -333,6 +339,13 @@ impl Transaction { Ok(()) } + /// Embed media into the transaction. + pub fn embed(&mut self, embeds: impl IntoIterator) -> Result<(), Error> { + self.embeds.extend(embeds); + + Ok(()) + } + /// Commit transaction. /// /// Returns an operation that can be applied onto an in-memory state. @@ -353,7 +366,7 @@ impl Transaction { head, object, parents, - } = store.update(id, msg, actions.clone(), signer)?; + } = store.update(id, msg, actions.clone(), self.embeds, signer)?; let id = EntryId::from(head); let author = self.actor; let timestamp = Timestamp::from_secs(object.history().timestamp()); diff --git a/radicle/src/cob/thread.rs b/radicle/src/cob/thread.rs index 32897fa6..bcdf7095 100644 --- a/radicle/src/cob/thread.rs +++ b/radicle/src/cob/thread.rs @@ -7,8 +7,8 @@ use serde::{ser::SerializeStruct, Deserialize, Serialize}; use thiserror::Error; use crate::cob; -use crate::cob::common::{Reaction, Timestamp}; -use crate::cob::{ActorId, EntryId, Op}; +use crate::cob::common::{Reaction, Timestamp, Uri}; +use crate::cob::{ActorId, Embed, EntryId, Op}; use crate::prelude::ReadRepository; /// Type name of a thread, as well as the domain for all thread operations. @@ -49,6 +49,8 @@ pub struct Edit { pub timestamp: Timestamp, /// Edit contents. Replaces previous edits. pub body: String, + /// Edit embed list. + pub embeds: Vec>, } /// A comment on a discussion thread. @@ -72,7 +74,7 @@ impl Serialize for Comment { where S: serde::ser::Serializer, { - let mut state = serializer.serialize_struct("Comment", 5)?; + let mut state = serializer.serialize_struct("Comment", 6)?; state.serialize_field("author", &self.author())?; if let Some(loc) = &self.location { state.serialize_field("location", loc)?; @@ -82,6 +84,7 @@ impl Serialize for Comment { } state.serialize_field("reactions", &self.reactions)?; state.serialize_field("body", self.body())?; + state.serialize_field("embeds", self.embeds())?; state.end() } } @@ -93,9 +96,14 @@ impl Comment { body: String, reply_to: Option, location: Option, + embeds: Vec>, timestamp: Timestamp, ) -> Self { - let edit = Edit { body, timestamp }; + let edit = Edit { + body, + embeds, + timestamp, + }; Self { author, @@ -139,8 +147,12 @@ impl Comment { } /// Add an edit. - pub fn edit(&mut self, body: String, timestamp: Timestamp) { - self.edits.push(Edit { body, timestamp }); + pub fn edit(&mut self, body: String, embeds: Vec>, timestamp: Timestamp) { + self.edits.push(Edit { + body, + embeds, + timestamp, + }); } /// Comment reactions. @@ -152,6 +164,14 @@ impl Comment { pub fn location(&self) -> Option<&L> { self.location.as_ref() } + + /// Return the embedded media. + pub fn embeds(&self) -> &[Embed] { + // SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`] + // constructor. + #[allow(clippy::unwrap_used)] + &self.edits.last().unwrap().embeds + } } impl PartialOrd for Comment { @@ -283,7 +303,7 @@ impl cob::store::FromHistory for Thread { type Error = Error; fn type_name() -> &'static radicle_cob::TypeName { - &*TYPENAME + &TYPENAME } fn validate(&self) -> Result<(), Self::Error> { @@ -301,10 +321,10 @@ impl cob::store::FromHistory for Thread { for action in op.actions { match action { Action::Comment { body, reply_to } => { - comment(self, id, author, timestamp, body, reply_to, None)?; + comment(self, id, author, timestamp, body, reply_to, None, vec![])?; } Action::Edit { id, body } => { - edit(self, op.id, id, timestamp, body)?; + edit(self, op.id, id, timestamp, body, vec![])?; } Action::Redact { id } => { redact(self, op.id, id)?; @@ -330,6 +350,7 @@ pub fn comment( body: String, reply_to: Option, location: Option, + embeds: Vec>, ) -> Result<(), Error> { if body.is_empty() { return Err(Error::Comment(id)); @@ -341,7 +362,9 @@ pub fn comment( // underlying store guarantees exactly-once delivery of ops. thread.comments.insert( id, - Some(Comment::new(author, body, reply_to, location, timestamp)), + Some(Comment::new( + author, body, reply_to, location, embeds, timestamp, + )), ); Ok(()) @@ -353,6 +376,7 @@ pub fn edit( comment: EntryId, timestamp: Timestamp, body: String, + embeds: Vec>, ) -> Result<(), Error> { if body.is_empty() { return Err(Error::Edit(id)); @@ -367,7 +391,7 @@ pub fn edit( // that as an error. if let Some(comment) = thread.comments.get_mut(&comment) { if let Some(comment) = comment { - comment.edit(body, timestamp); + comment.edit(body, embeds, timestamp); } } else { return Err(Error::Missing(comment)); diff --git a/radicle/src/lib.rs b/radicle/src/lib.rs index a740ed0e..bb99974c 100644 --- a/radicle/src/lib.rs +++ b/radicle/src/lib.rs @@ -1,5 +1,5 @@ #![allow(clippy::match_like_matches_macro)] -#![allow(clippy::explicit_auto_deref)] // TODO: This can be removed when the clippy bugs are fixed +#![allow(clippy::too_many_arguments)] #![allow(clippy::iter_nth_zero)] pub extern crate radicle_crypto as crypto; diff --git a/radicle/src/storage.rs b/radicle/src/storage.rs index 7cb0fa65..e1d160ba 100644 --- a/radicle/src/storage.rs +++ b/radicle/src/storage.rs @@ -333,6 +333,9 @@ pub trait ReadRepository: Sized { fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path) -> Result, git_ext::Error>; + /// Get a blob in this repository, given its id. + fn blob(&self, oid: Oid) -> Result; + /// Validate all remotes with [`ReadRepository::validate_remote`]. fn validate(&self) -> Result<(), VerifyError> { for (_, remote) in self.remotes()? { diff --git a/radicle/src/storage/git.rs b/radicle/src/storage/git.rs index d35ee44b..1cfeb996 100644 --- a/radicle/src/storage/git.rs +++ b/radicle/src/storage/git.rs @@ -357,14 +357,18 @@ impl ReadRepository for Repository { self.backend.path() } - fn blob_at<'a>(&'a self, oid: Oid, path: &'a Path) -> Result, git::Error> { + fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path) -> Result, git::Error> { git::ext::Blob::At { - object: oid.into(), + object: commit.into(), path, } .get(&self.backend) } + fn blob(&self, oid: Oid) -> Result { + self.backend.find_blob(oid.into()).map_err(git::Error::from) + } + fn validate_remote(&self, remote: &Remote) -> Result, VerifyError> { // Contains a copy of the signed refs of this remote. let mut signed = BTreeMap::from((*remote.refs).clone()); diff --git a/radicle/src/storage/git/cob.rs b/radicle/src/storage/git/cob.rs index 6b41b731..eb58e635 100644 --- a/radicle/src/storage/git/cob.rs +++ b/radicle/src/storage/git/cob.rs @@ -283,6 +283,10 @@ impl<'a> ReadRepository for DraftStore<'a> { self.repo.blob_at(oid, path) } + fn blob(&self, oid: git_ext::Oid) -> Result { + self.repo.blob(oid) + } + fn reference( &self, remote: &RemoteId, diff --git a/radicle/src/test/storage.rs b/radicle/src/test/storage.rs index 586f391a..26d0b6c9 100644 --- a/radicle/src/test/storage.rs +++ b/radicle/src/test/storage.rs @@ -178,6 +178,10 @@ impl ReadRepository for MockRepository { Ok(true) } + fn blob(&self, _oid: Oid) -> Result { + todo!() + } + fn blob_at<'a>( &'a self, _oid: git_ext::Oid,