Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Publish Draft Changes
Archived fintohaps opened 1 year ago

This patch introduces an API for publishing draft changes ensuring that the history of any published changes are compatible and preserved.

24 files changed +1114 -65 3b5fac17 6a7e47c8
modified Cargo.lock
@@ -1966,6 +1966,7 @@ dependencies = [
 "log",
 "nonempty 0.9.0",
 "once_cell",
+
 "pretty_assertions",
 "qcheck",
 "qcheck-macros",
 "radicle-crypto",
modified radicle-cob/Cargo.toml
@@ -47,10 +47,11 @@ features = ["derive"]
[dev-dependencies]
fastrand = { version = "2.0.0", default-features = false }
tempfile = { version = "3" }
+
pretty_assertions = { version = "1" }
qcheck = { version = "1", default-features = false }
qcheck-macros = { version = "1", default-features = false }

[dev-dependencies.radicle-crypto]
path = "../radicle-crypto"
version = "0"
-
features = ["test"]
+
features = ["test", "radicle-git-ext"]
modified radicle-cob/src/backend/git/change.rs
@@ -12,6 +12,7 @@ use once_cell::sync::Lazy;
use radicle_git_ext::commit::trailers::OwnedTrailer;

use crate::change::store::Version;
+
use crate::change::{ChangeEntry, MergeEntry};
use crate::signatures;
use crate::trailers::CommitTrailer;
use crate::{
@@ -23,6 +24,8 @@ use crate::{

/// Name of the COB manifest file.
pub const MANIFEST_BLOB_NAME: &str = "manifest";
+
/// The kind of change entry a commit represents.
+
pub const ENTRY_KIND: &str = "kind";
/// Path under which COB embeds are kept.
pub static EMBEDS_PATH: Lazy<PathBuf> = Lazy::new(|| PathBuf::from("embeds"));

@@ -70,6 +73,14 @@ pub mod error {
            #[source]
            err: serde_json::Error,
        },
+
        #[error("the 'kind' found at '{0}' was not a blob")]
+
        EntryKindIsNotBlob(Oid),
+
        #[error("the 'kinf' found at '{id}' was invalid: {err}")]
+
        InvalidEntryKind {
+
            id: Oid,
+
            #[source]
+
            err: serde_json::Error,
+
        },
        #[error("a 'change' file was expected be found in '{0}'")]
        NoChange(Oid),
        #[error("the 'change' found at '{0}' was not a blob")]
@@ -155,6 +166,43 @@ impl change::Storage for git2::Repository {
        })
    }

+
    fn merge<G>(
+
        &self,
+
        tips: Vec<Self::ObjectId>,
+
        signer: &G,
+
        type_name: crate::TypeName,
+
        message: String,
+
    ) -> Result<store::MergeEntry<Self::Parent, Self::ObjectId, Self::Signatures>, Self::StoreError>
+
    where
+
        G: crypto::Signer,
+
    {
+
        let manifest = store::Manifest::new(type_name, Version::default());
+
        let revision = write_merge_manifest(self, &manifest)?;
+
        let tree = self.find_tree(revision)?;
+
        let signature = {
+
            let sig = signer.sign(revision.as_bytes());
+
            let key = signer.public_key();
+
            ExtendedSignature::new(*key, sig)
+
        };
+
        let (id, timestamp) = write_commit(
+
            self,
+
            None,
+
            tips.iter().copied().map(git2::Oid::from),
+
            message,
+
            signature.clone(),
+
            None,
+
            tree,
+
        )?;
+
        Ok(MergeEntry {
+
            id,
+
            revision: revision.into(),
+
            signature,
+
            parents: tips,
+
            manifest,
+
            timestamp,
+
        })
+
    }
+

    fn parents_of(&self, id: &Oid) -> Result<Vec<Oid>, Self::LoadError> {
        Ok(self
            .find_commit(**id)?
@@ -163,7 +211,7 @@ impl change::Storage for git2::Repository {
            .collect::<Vec<_>>())
    }

-
    fn load(&self, id: Self::ObjectId) -> Result<Entry, Self::LoadError> {
+
    fn load(&self, id: Self::ObjectId) -> Result<ChangeEntry, Self::LoadError> {
        let commit = Commit::read(self, id.into())?;
        let timestamp = git2::Time::from(commit.committer().time).seconds() as u64;
        let trailers = parse_trailers(commit.trailers())?;
@@ -199,19 +247,32 @@ impl change::Storage for git2::Repository {

        let tree = self.find_tree(*commit.tree())?;
        let manifest = load_manifest(self, &tree)?;
-
        let contents = load_contents(self, &tree)?;
-

-
        Ok(Entry {
-
            id,
-
            revision: tree.id().into(),
-
            signature: ExtendedSignature::new(key, sig),
-
            resource: resources.pop(),
-
            related,
-
            parents,
-
            manifest,
-
            contents,
-
            timestamp,
-
        })
+
        let kind = load_entry_kind(self, &tree)?;
+

+
        match kind {
+
            store::EntryKind::Merge => Ok(ChangeEntry::Merge(MergeEntry {
+
                id,
+
                revision: tree.id().into(),
+
                signature: ExtendedSignature::new(key, sig),
+
                parents,
+
                manifest,
+
                timestamp,
+
            })),
+
            store::EntryKind::Commit => {
+
                let contents = load_contents(self, &tree)?;
+
                Ok(ChangeEntry::Entry(Entry {
+
                    id,
+
                    revision: tree.id().into(),
+
                    signature: ExtendedSignature::new(key, sig),
+
                    resource: resources.pop(),
+
                    related,
+
                    parents,
+
                    manifest,
+
                    contents,
+
                    timestamp,
+
                }))
+
            }
+
        }
    }
}

@@ -249,6 +310,24 @@ fn load_manifest(
    })
}

+
fn load_entry_kind(
+
    repo: &git2::Repository,
+
    tree: &git2::Tree,
+
) -> Result<store::EntryKind, error::Load> {
+
    tree.get_name(ENTRY_KIND)
+
        .map_or(Ok(store::EntryKind::Commit), |entry| {
+
            let object = entry.to_object(repo)?;
+
            let blob = object
+
                .as_blob()
+
                .ok_or_else(|| error::Load::EntryKindIsNotBlob(tree.id().into()))?;
+

+
            serde_json::from_slice(blob.content()).map_err(|err| error::Load::InvalidEntryKind {
+
                id: tree.id().into(),
+
                err,
+
            })
+
        })
+
}
+

fn load_contents(repo: &git2::Repository, tree: &git2::Tree) -> Result<Contents, error::Load> {
    let ops = tree
        .iter()
@@ -341,6 +420,36 @@ fn write_commit(
    Ok((Oid::from(oid), timestamp as u64))
}

+
fn write_merge_manifest(
+
    repo: &git2::Repository,
+
    manifest: &store::Manifest,
+
) -> Result<git2::Oid, git2::Error> {
+
    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();
+
        #[allow(clippy::unwrap_used)]
+
        let kind = serde_json::to_vec(&store::EntryKind::Merge).unwrap();
+

+
        let manifest_oid = repo.blob(&manifest)?;
+
        root.insert(
+
            MANIFEST_BLOB_NAME,
+
            manifest_oid,
+
            git2::FileMode::Blob.into(),
+
        )?;
+

+
        let kind_oid = repo.blob(&kind)?;
+
        root.insert(ENTRY_KIND, kind_oid, git2::FileMode::Blob.into())?;
+
    }
+
    let oid = root.write()?;
+

+
    Ok(oid)
+
}
+

fn write_manifest(
    repo: &git2::Repository,
    manifest: &store::Manifest,
@@ -349,19 +458,23 @@ fn write_manifest(
) -> Result<git2::Oid, git2::Error> {
    let mut root = repo.treebuilder(None)?;

-
    // Insert manifest file into tree.
+
    // Insert manifest file and entry kind 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)?;
+
        #[allow(clippy::unwrap_used)]
+
        let kind = serde_json::to_vec(&store::EntryKind::Commit).unwrap();
+
        let kind_oid = repo.blob(&kind)?;

        root.insert(
            MANIFEST_BLOB_NAME,
            manifest_oid,
            git2::FileMode::Blob.into(),
        )?;
+
        root.insert(ENTRY_KIND, kind_oid, git2::FileMode::Blob.into())?;
    }

    // Insert each COB entry.
modified radicle-cob/src/change.rs
@@ -9,3 +9,5 @@ use crate::signatures::ExtendedSignature;

/// A single change in the change graph.
pub type Entry = store::Entry<Oid, Oid, ExtendedSignature>;
+
pub type MergeEntry = store::MergeEntry<Oid, Oid, ExtendedSignature>;
+
pub type ChangeEntry = store::ChangeEntry<Oid, Oid, ExtendedSignature>;
modified radicle-cob/src/change/store.rs
@@ -29,12 +29,24 @@ pub trait Storage {
    where
        G: crypto::Signer;

+
    /// Merge a set of entries into a [`MergeEntry`].
+
    #[allow(clippy::type_complexity)]
+
    fn merge<G>(
+
        &self,
+
        tips: Vec<Self::ObjectId>,
+
        signer: &G,
+
        type_name: TypeName,
+
        message: String,
+
    ) -> Result<MergeEntry<Self::ObjectId, Self::ObjectId, Self::Signatures>, Self::StoreError>
+
    where
+
        G: crypto::Signer;
+

    /// Load a change entry.
    #[allow(clippy::type_complexity)]
    fn load(
        &self,
        id: Self::ObjectId,
-
    ) -> Result<Entry<Self::Parent, Self::ObjectId, Self::Signatures>, Self::LoadError>;
+
    ) -> Result<ChangeEntry<Self::Parent, Self::ObjectId, Self::Signatures>, Self::LoadError>;

    /// Returns the parents of the object with the specified ID.
    fn parents_of(&self, id: &Oid) -> Result<Vec<Oid>, Self::LoadError>;
@@ -49,6 +61,15 @@ pub struct Template<Id> {
    pub contents: NonEmpty<Vec<u8>>,
}

+
/// Change template, used to create a new change.
+
pub struct MergeTemplate<Id> {
+
    pub type_name: TypeName,
+
    pub tips: Vec<Id>,
+
    pub message: String,
+
    pub embeds: Vec<Embed<Oid>>,
+
    pub contents: NonEmpty<Vec<u8>>,
+
}
+

/// Entry contents.
/// This is the change payload.
pub type Contents = NonEmpty<Vec<u8>>;
@@ -59,6 +80,81 @@ pub type Timestamp = u64;
/// A unique identifier for a history entry.
pub type EntryId = Oid;

+
/// An entry for a change made in the store.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum ChangeEntry<Resource, Id, Signature> {
+
    /// A single entry that contains contents.
+
    Entry(Entry<Resource, Id, Signature>),
+
    /// A merge of two or more entries.
+
    Merge(MergeEntry<Resource, Id, Signature>),
+
}
+

+
impl<R, I, S> ChangeEntry<R, I, S> {
+
    /// Get the identifier for the change.
+
    pub fn id(&self) -> &I {
+
        match self {
+
            ChangeEntry::Entry(change) => &change.id,
+
            ChangeEntry::Merge(change) => &change.id,
+
        }
+
    }
+

+
    /// Get the parent identifier of the change.
+
    pub fn parents(&self) -> &Vec<R> {
+
        match self {
+
            ChangeEntry::Entry(change) => &change.parents,
+
            ChangeEntry::Merge(change) => &change.parents,
+
        }
+
    }
+

+
    /// Get the optional resource identifier.
+
    pub fn resource(&self) -> Option<&R> {
+
        match self {
+
            ChangeEntry::Entry(change) => change.resource(),
+
            ChangeEntry::Merge(_) => None,
+
        }
+
    }
+

+
    /// Get the timestamp this change occurred at.
+
    pub fn timestamp(&self) -> &Timestamp {
+
        match self {
+
            ChangeEntry::Entry(c) => &c.timestamp,
+
            ChangeEntry::Merge(c) => &c.timestamp,
+
        }
+
    }
+

+
    /// Convert the `ChangeEntry` into its underlying [`Entry`].
+
    ///
+
    /// Returns `None` is it is a [`MergeEntry`].
+
    pub fn as_entry(&self) -> Option<&Entry<R, I, S>> {
+
        match self {
+
            ChangeEntry::Entry(e) => Some(e),
+
            ChangeEntry::Merge(_) => None,
+
        }
+
    }
+

+
    /// Convert the `ChangeEntry` into its underlying [`Entry`].
+
    ///
+
    /// Returns `None` is it is a [`MergeEntry`].
+
    pub fn into_entry(self) -> Option<Entry<R, I, S>> {
+
        match self {
+
            ChangeEntry::Entry(e) => Some(e),
+
            ChangeEntry::Merge(_) => None,
+
        }
+
    }
+
}
+

+
impl<R, I, S> From<Entry<R, I, S>> for ChangeEntry<R, I, S> {
+
    fn from(entry: Entry<R, I, S>) -> Self {
+
        Self::Entry(entry)
+
    }
+
}
+

+
impl<R, I, S> From<MergeEntry<R, I, S>> for ChangeEntry<R, I, S> {
+
    fn from(entry: MergeEntry<R, I, S>) -> Self {
+
        Self::Merge(entry)
+
    }
+
}
+

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry<Resource, Id, Signature> {
    /// The content address of the entry itself.
@@ -122,6 +218,25 @@ where
    }
}

+
impl<R, Id> ChangeEntry<R, Id, signatures::ExtendedSignature>
+
where
+
    Id: AsRef<[u8]>,
+
{
+
    pub fn valid_signatures(&self) -> bool {
+
        match self {
+
            ChangeEntry::Entry(c) => c.valid_signatures(),
+
            ChangeEntry::Merge(c) => c.valid_signatures(),
+
        }
+
    }
+

+
    pub fn author(&self) -> &crypto::PublicKey {
+
        match self {
+
            ChangeEntry::Entry(c) => c.author(),
+
            ChangeEntry::Merge(c) => c.author(),
+
        }
+
    }
+
}
+

impl<R, Id> Entry<R, Id, signatures::ExtendedSignature>
where
    Id: AsRef<[u8]>,
@@ -135,6 +250,37 @@ where
    }
}

+
impl<R, Id> MergeEntry<R, Id, signatures::ExtendedSignature>
+
where
+
    Id: AsRef<[u8]>,
+
{
+
    pub fn valid_signatures(&self) -> bool {
+
        self.signature.verify(self.revision.as_ref())
+
    }
+

+
    pub fn author(&self) -> &crypto::PublicKey {
+
        &self.signature.key
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct MergeEntry<Resource, Id, Signature> {
+
    /// The content address of the entry itself.
+
    pub id: Id,
+
    /// The content address of the tree of the entry.
+
    pub revision: Id,
+
    /// The cryptographic signature(s) and their public keys of the
+
    /// authors.
+
    pub signature: Signature,
+
    /// The set of entries that are being merged.
+
    pub parents: Vec<Resource>,
+
    /// The manifest describing the type of object as well as the type
+
    /// of history for this entry.
+
    pub manifest: Manifest,
+
    /// Timestamp of change.
+
    pub timestamp: Timestamp,
+
}
+

#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
@@ -153,6 +299,13 @@ impl Manifest {
    }
}

+
#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, Serialize, Deserialize)]
+
pub enum EntryKind {
+
    Merge,
+
    #[default]
+
    Commit,
+
}
+

/// COB version.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Version(NonZeroUsize);
modified radicle-cob/src/change_graph.rs
@@ -1,14 +1,15 @@
// Copyright © 2021 The Radicle Link Contributors

-
use std::ops::ControlFlow;
+
use std::ops::{ControlFlow, Deref};
use std::{cmp::Ordering, collections::BTreeSet};

use git_ext::Oid;
use radicle_dag::Dag;

+
use crate::ChangeEntry;
use crate::{
    change, object, object::collaboration::Evaluate, signatures::ExtendedSignature,
-
    CollaborativeObject, Entry, EntryId, History, ObjectId, TypeName,
+
    CollaborativeObject, EntryId, History, ObjectId, TypeName,
};

#[derive(Debug, thiserror::Error)]
@@ -24,7 +25,7 @@ pub enum EvaluateError {
/// The graph of changes for a particular collaborative object
pub(super) struct ChangeGraph {
    object_id: ObjectId,
-
    graph: Dag<Oid, Entry>,
+
    graph: Dag<Oid, ChangeEntry>,
}

impl ChangeGraph {
@@ -100,18 +101,22 @@ impl ChangeGraph {
        let root = self
            .graph
            .get(&root)
-
            .ok_or(EvaluateError::MissingRoot(root))?;
+
            .ok_or_else(|| EvaluateError::MissingRoot(root))?;

        if !root.valid_signatures() {
-
            return Err(EvaluateError::Signature(root.id));
+
            return Err(EvaluateError::Signature(*root.id()));
        }
+
        let Some(root) = root.clone().map_value(|e| e.into_entry()).transpose_value() else {
+
            return Err(EvaluateError::Init(
+
                "root must not be a merge entry".to_string().into(),
+
            ));
+
        };
        // Evaluate the root separately, since we can't have a COB without a valid root.
        // Then, traverse the graph starting from the root's dependents.
-
        let mut object =
-
            T::init(&root.value, store).map_err(|e| EvaluateError::Init(Box::new(e)))?;
+
        let mut object = T::init(&root, store).map_err(|e| EvaluateError::Init(Box::new(e)))?;
        let children = Vec::from_iter(root.dependents.iter().cloned());
        let manifest = root.manifest.clone();
-
        let root = root.id;
+
        let root = root.id();

        self.graph.prune_by(
            &children,
@@ -120,14 +125,24 @@ impl ChangeGraph {
                if !entry.valid_signatures() {
                    return ControlFlow::Break(());
                }
-
                // Apply the entry to the state, and if there's an error, prune that branch.
-
                if object
-
                    .apply(entry, siblings.map(|(k, n)| (k, &n.value)), store)
-
                    .is_err()
-
                {
-
                    return ControlFlow::Break(());
+
                match entry.deref() {
+
                    change::store::ChangeEntry::Entry(entry) => {
+
                        // Apply the entry to the state, and if there's an error, prune that branch.
+
                        if object
+
                            .apply(
+
                                entry,
+
                                siblings.filter_map(|(k, n)| n.value.as_entry().map(|e| (k, e))),
+
                                store,
+
                            )
+
                            .is_err()
+
                        {
+
                            ControlFlow::Break(())
+
                        } else {
+
                            ControlFlow::Continue(())
+
                        }
+
                    }
+
                    change::store::ChangeEntry::Merge(_) => ControlFlow::Continue(()),
                }
-
                ControlFlow::Continue(())
            },
            Self::chronological,
        );
@@ -135,7 +150,7 @@ impl ChangeGraph {
        Ok(CollaborativeObject {
            manifest,
            object,
-
            history: History::new(root, self.graph),
+
            history: History::new(*root, self.graph),
            id: self.object_id,
        })
    }
@@ -149,13 +164,13 @@ impl ChangeGraph {
        self.graph.len()
    }

-
    fn chronological(x: (&Oid, &Entry), y: (&Oid, &Entry)) -> Ordering {
-
        x.1.timestamp.cmp(&y.1.timestamp).then(x.0.cmp(y.0))
+
    fn chronological(x: (&Oid, &ChangeEntry), y: (&Oid, &ChangeEntry)) -> Ordering {
+
        x.1.timestamp().cmp(y.1.timestamp()).then(x.0.cmp(y.0))
    }
}

struct GraphBuilder {
-
    graph: Dag<Oid, Entry>,
+
    graph: Dag<Oid, ChangeEntry>,
}

impl Default for GraphBuilder {
@@ -167,9 +182,9 @@ impl Default for GraphBuilder {
impl GraphBuilder {
    /// Add a change to the graph which we are building up, returning any edges
    /// corresponding to the parents of this node in the change graph
-
    fn add_change(&mut self, commit_id: Oid, change: Entry) -> Vec<(Oid, Oid)> {
+
    fn add_change(&mut self, commit_id: Oid, change: ChangeEntry) -> Vec<(Oid, Oid)> {
        let resource = change.resource().copied();
-
        let parents = change.parents.clone();
+
        let parents = change.parents().clone();

        if !self.graph.contains(&commit_id) {
            self.graph.node(commit_id, change);
modified radicle-cob/src/history.rs
@@ -1,16 +1,20 @@
// Copyright © 2021 The Radicle Link Contributors
#![allow(clippy::too_many_arguments)]
-
use std::{cmp::Ordering, collections::BTreeSet, ops::ControlFlow};
+
use std::{
+
    cmp::Ordering,
+
    collections::BTreeSet,
+
    ops::{ControlFlow, Deref},
+
};

use git_ext::Oid;
use radicle_dag::Dag;

-
pub use crate::change::{Contents, Entry, EntryId, Timestamp};
+
pub use crate::change::{ChangeEntry, Contents, Entry, EntryId, Timestamp};

/// The DAG of changes making up the history of a collaborative object.
#[derive(Clone, Debug)]
pub struct History {
-
    graph: Dag<EntryId, Entry>,
+
    graph: Dag<EntryId, ChangeEntry>,
    root: EntryId,
}

@@ -24,11 +28,18 @@ impl Eq for History {}

impl History {
    /// Create a new history from a DAG. Panics if the root is not part of the graph.
-
    pub fn new(root: EntryId, graph: Dag<EntryId, Entry>) -> Self {
+
    pub fn new(root: EntryId, graph: Dag<EntryId, ChangeEntry>) -> Self {
        assert!(
            graph.contains(&root),
            "History::new: root must be present in graph"
        );
+
        assert!(
+
            graph
+
                .get(&root)
+
                .and_then(|entry| entry.as_entry())
+
                .is_some(),
+
            "History::new: root must be an `Entry` variant",
+
        );
        Self { root, graph }
    }

@@ -36,7 +47,7 @@ impl History {
    pub fn new_from_root(root: Entry) -> Self {
        let id = *root.id();

-
        Self::new(id, Dag::root(id, root))
+
        Self::new(id, Dag::root(id, ChangeEntry::Entry(root)))
    }

    /// Get all the tips of the graph.
@@ -54,7 +65,10 @@ impl History {
    where
        F: for<'r> FnMut(A, &'r EntryId, &'r Entry) -> ControlFlow<A, A>,
    {
-
        self.graph.fold(roots, init, |acc, k, v| f(acc, k, v))
+
        self.graph.fold(roots, init, |acc, k, v| match v.deref() {
+
            ChangeEntry::Entry(v) => f(acc, k, v),
+
            _ => ControlFlow::Continue(acc),
+
        })
    }

    /// Return a topologically-sorted list of history entries.
@@ -66,11 +80,14 @@ impl History {
            .sorted_by(compare)
            .into_iter()
            .filter_map(|k| self.graph.get(&k))
-
            .map(|node| &node.value)
+
            .filter_map(|node| match &node.value {
+
                crate::change::store::ChangeEntry::Entry(entry) => Some(entry),
+
                crate::change::store::ChangeEntry::Merge(_) => None,
+
            })
    }

    /// Extend this history with a new entry.
-
    pub fn extend(&mut self, change: Entry) {
+
    pub fn extend(&mut self, change: ChangeEntry) {
        let tips = self.tips();
        let id = *change.id();

@@ -97,16 +114,19 @@ impl History {
    }

    /// Get the underlying graph
-
    pub fn graph(&self) -> &Dag<EntryId, Entry> {
+
    pub fn graph(&self) -> &Dag<EntryId, ChangeEntry> {
        &self.graph
    }

    /// Get the root entry.
    pub fn root(&self) -> &Entry {
-
        // SAFETY: We don't allow construction of histories without a root.
+
        // SAFETY: We don't allow construction of histories without a root and
+
        // the root must be the `Entry` variant.
        self.graph
            .get(&self.root)
            .expect("History::root: the root entry must be present in the graph")
+
            .as_entry()
+
            .expect("History::root: the root entry must be an `Entry` variant")
    }

    /// Get the children of the given entry.
modified radicle-cob/src/lib.rs
@@ -23,6 +23,10 @@
//!   * [`list`]
//!   * [`update`]
//!
+
//! There is an additional [`merge`] function that allows an interface for
+
//! keeping track of draft changes on a separate reference, which can then be
+
//! merged back into the published reference.
+
//!
//! ## Storage
//!
//! The storing of collaborative objects is based on a git
@@ -70,7 +74,7 @@ mod trailers;

pub mod change;
pub use change::store::{Contents, Embed, EntryId, Manifest, Version};
-
pub use change::Entry;
+
pub use change::{ChangeEntry, Entry, MergeEntry};

pub mod history;
pub use history::History;
@@ -83,8 +87,8 @@ pub use type_name::TypeName;

pub mod object;
pub use object::{
-
    create, get, info, list, remove, update, CollaborativeObject, Create, Evaluate, ObjectId,
-
    Update, Updated,
+
    create, get, info, list, merge, remove, update, CollaborativeObject, Create, Evaluate, Merge,
+
    Merged, ObjectId, Update, Updated,
};

#[cfg(test)]
modified radicle-cob/src/object.rs
@@ -9,8 +9,8 @@ use thiserror::Error;

pub mod collaboration;
pub use collaboration::{
-
    create, get, info, list, parse_refstr, remove, update, CollaborativeObject, Create, Evaluate,
-
    Update, Updated,
+
    create, get, info, list, merge, parse_refstr, remove, update, CollaborativeObject, Create,
+
    Evaluate, Merge, Merged, Update, Updated,
};

pub mod storage;
modified radicle-cob/src/object/collaboration.rs
@@ -21,6 +21,9 @@ pub mod info;
mod list;
pub use list::list;

+
mod merge;
+
pub use merge::{merge, Draft, Merge, Merged, Published};
+

mod remove;
pub use remove::remove;

modified radicle-cob/src/object/collaboration/error.rs
@@ -56,6 +56,32 @@ impl Retrieve {
}

#[derive(Debug, Error)]
+
pub enum Merge {
+
    #[error(transparent)]
+
    Evaluate(Box<dyn std::error::Error + Send + Sync + 'static>),
+
    #[error("no object found")]
+
    NoSuchObject,
+
    #[error(transparent)]
+
    CreateChange(#[from] git::change::error::Create),
+
    #[error("failed to get references during object merge: {err}")]
+
    Refs {
+
        #[source]
+
        err: Box<dyn std::error::Error + Send + Sync + 'static>,
+
    },
+
    #[error("failed to remove the merged draft: {err}")]
+
    Remove {
+
        #[source]
+
        err: Box<dyn std::error::Error + Send + Sync + 'static>,
+
    },
+
}
+

+
impl Merge {
+
    pub(crate) fn evaluate(err: impl std::error::Error + Send + Sync + 'static) -> Self {
+
        Self::Evaluate(Box::new(err))
+
    }
+
}
+

+
#[derive(Debug, Error)]
pub enum Update {
    #[error(transparent)]
    Evaluate(Box<dyn std::error::Error + Send + Sync + 'static>),
modified radicle-cob/src/object/collaboration/info.rs
@@ -13,6 +13,7 @@ use crate::{change_graph::ChangeGraph, ObjectId, Store, TypeName};
use super::error;

/// Additional information about the change graph of an object
+
#[derive(Debug)]
pub struct ChangeGraphInfo {
    /// The ID of the object
    pub object_id: ObjectId,
added radicle-cob/src/object/collaboration/merge.rs
@@ -0,0 +1,138 @@
+
use git_ext::Oid;
+
use radicle_crypto::PublicKey;
+

+
use crate::{
+
    change_graph::ChangeGraph,
+
    history::EntryId,
+
    object::{Commit, Reference},
+
    CollaborativeObject, Evaluate, ObjectId, Store, TypeName,
+
};
+

+
use super::error;
+

+
/// Result of the [`merge`] operation.
+
pub struct Merged<T> {
+
    /// The new head commit of the DAG.
+
    pub head: Oid,
+
    /// The newly updated collaborative object.
+
    pub object: CollaborativeObject<T>,
+
    /// Entry parents.
+
    pub parents: Vec<EntryId>,
+
}
+

+
/// The date required to perform the [`merge`] operation.
+
pub struct Merge {
+
    /// The typename of the object to be merged.
+
    pub type_name: TypeName,
+
    /// The object ID of the object to be merged.
+
    pub object_id: ObjectId,
+
    /// The message to add when updating this object.
+
    pub message: String,
+
}
+

+
/// A newtype to ensure that the `Draft` store is distinguished from the
+
/// [`Published`] store when calling the [`merge`] function.
+
pub struct Draft<'a, S>(&'a S);
+

+
impl<'a, S> Draft<'a, S> {
+
    pub fn new(s: &'a S) -> Self {
+
        Self(s)
+
    }
+
}
+

+
impl<'a, S> AsRef<S> for Draft<'a, S> {
+
    fn as_ref(&self) -> &S {
+
        self.0
+
    }
+
}
+

+
/// A newtype to ensure that the `Published` store is distinguished from the
+
/// [`Draft`] store when calling the [`merge`] function.
+
pub struct Published<'a, S>(&'a S);
+

+
impl<'a, S> Published<'a, S> {
+
    pub fn new(s: &'a S) -> Self {
+
        Self(s)
+
    }
+
}
+

+
impl<'a, S> AsRef<S> for Published<'a, S> {
+
    fn as_ref(&self) -> &S {
+
        self.0
+
    }
+
}
+

+
/// Merge the changes made to a [`CollaborativeObject`], in the `draft` storage,
+
/// into the changes of the `published` storage.
+
///
+
/// The changes must come from the same `identifier` and so the reference
+
/// related to the `identifier` is updated with new head produced by the merge.
+
pub fn merge<T, D, P, G>(
+
    draft: &Draft<D>,
+
    published: &Published<P>,
+
    signer: &G,
+
    identifier: &PublicKey,
+
    args: Merge,
+
) -> Result<Merged<T>, error::Merge>
+
where
+
    T: Evaluate<D>,
+
    T: Evaluate<P>,
+
    D: Store,
+
    P: Store,
+
    G: crypto::Signer,
+
{
+
    let draft = draft.as_ref();
+
    let published = published.as_ref();
+
    let Merge {
+
        type_name,
+
        object_id,
+
        message,
+
    } = args;
+
    // Get the existing reference tips from both the draft and published
+
    // stores.
+
    let mut existing_refs = draft
+
        .objects(&type_name, &object_id)
+
        .map_err(|err| error::Merge::Refs { err: Box::new(err) })?;
+
    existing_refs.extend(
+
        published
+
            .objects(&type_name, &object_id)
+
            .map_err(|err| error::Merge::Refs { err: Box::new(err) })?,
+
    );
+

+
    // Create the merge entry in the published store and update the
+
    // `identifier`'s reference with the new tip.
+
    let merge = published.merge(
+
        existing_refs
+
            .iter()
+
            .map(
+
                |Reference {
+
                     target: Commit { id },
+
                     ..
+
                 }| *id,
+
            )
+
            .collect(),
+
        signer,
+
        type_name.clone(),
+
        message,
+
    )?;
+
    let head = merge.id;
+
    published
+
        .update(identifier, &type_name, &object_id, &head)
+
        .map_err(|err| error::Merge::Refs { err: Box::new(err) })?;
+
    draft
+
        .remove(identifier, &type_name, &object_id)
+
        .map_err(|err| error::Merge::Remove { err: Box::new(err) })?;
+

+
    let new_refs = published
+
        .objects(&type_name, &object_id)
+
        .map_err(|err| error::Merge::Refs { err: Box::new(err) })?;
+
    let graph = ChangeGraph::load(published, new_refs.iter(), &type_name, &object_id)
+
        .ok_or(error::Merge::NoSuchObject)?;
+
    let object: CollaborativeObject<T> = graph.evaluate(draft).map_err(error::Merge::evaluate)?;
+

+
    Ok(Merged {
+
        head,
+
        object,
+
        parents: merge.parents.to_vec(),
+
    })
+
}
modified radicle-cob/src/object/collaboration/update.rs
@@ -110,7 +110,7 @@ where
        .object
        .apply(&entry, iter::empty(), storage)
        .map_err(error::Update::evaluate)?;
-
    object.history.extend(entry);
+
    object.history.extend(entry.into());

    // Here we actually update the references to point to the new update.
    storage
modified radicle-cob/src/object/storage.rs
@@ -36,6 +36,21 @@ impl From<Vec<Reference>> for Objects {
    }
}

+
impl Extend<Reference> for Objects {
+
    fn extend<T: IntoIterator<Item = Reference>>(&mut self, iter: T) {
+
        self.0.extend(iter)
+
    }
+
}
+

+
impl IntoIterator for Objects {
+
    type Item = Reference;
+
    type IntoIter = std::vec::IntoIter<Self::Item>;
+

+
    fn into_iter(self) -> Self::IntoIter {
+
        self.0.into_iter()
+
    }
+
}
+

/// A [`Reference`] that must directly point to the [`Commit`] for a
/// [`crate::CollaborativeObject`].
#[derive(Clone, Debug)]
modified radicle-cob/src/test.rs
@@ -2,6 +2,6 @@ pub mod identity;
pub use identity::{Person, Project, RemoteProject};

pub mod storage;
-
pub use storage::Storage;
+
pub use storage::{Drafts, Storage};

pub mod arbitrary;
modified radicle-cob/src/test/storage.rs
@@ -1,11 +1,12 @@
use std::{collections::BTreeMap, convert::TryFrom as _};

+
use git_ext::ref_format::{refname, Component, RefString};
use radicle_crypto::PublicKey;
use tempfile::TempDir;

use crate::{
    change,
-
    object::{self, Reference},
+
    object::{self, Commit, Reference},
    ObjectId, Store,
};

@@ -84,8 +85,10 @@ impl change::Storage for Storage {
    fn load(
        &self,
        id: Self::ObjectId,
-
    ) -> Result<change::store::Entry<Self::Parent, Self::ObjectId, Self::Signatures>, Self::LoadError>
-
    {
+
    ) -> Result<
+
        change::store::ChangeEntry<Self::Parent, Self::ObjectId, Self::Signatures>,
+
        Self::LoadError,
+
    > {
        self.as_raw().load(id)
    }

@@ -97,6 +100,22 @@ impl change::Storage for Storage {
            .map(git_ext::Oid::from)
            .collect::<Vec<_>>())
    }
+

+
    fn merge<G>(
+
        &self,
+
        tips: Vec<Self::ObjectId>,
+
        signer: &G,
+
        type_name: crate::TypeName,
+
        message: String,
+
    ) -> Result<
+
        change::store::MergeEntry<Self::Parent, Self::ObjectId, Self::Signatures>,
+
        Self::StoreError,
+
    >
+
    where
+
        G: crypto::Signer,
+
    {
+
        change::Storage::merge(self.as_raw(), tips, signer, type_name, message)
+
    }
}

impl object::Storage for Storage {
@@ -130,7 +149,6 @@ impl object::Storage for Storage {
        for r in self.raw.references_glob("refs/rad/*")? {
            let r = r?;
            let name = r.name().unwrap();
-
            println!("NAME: {name}");
            let oid = r
                .target()
                .map(ObjectId::from)
@@ -171,3 +189,152 @@ impl object::Storage for Storage {
        Ok(())
    }
}
+

+
pub struct Drafts<'a> {
+
    inner: &'a Storage,
+
    remote: PublicKey,
+
}
+

+
impl<'a> Drafts<'a> {
+
    pub fn new(inner: &'a Storage, owner: PublicKey) -> Self {
+
        Self {
+
            inner,
+
            remote: owner,
+
        }
+
    }
+

+
    fn refstring(&self, typename: &crate::TypeName, object_id: &ObjectId) -> RefString {
+
        refname!("refs/drafts")
+
            .join(Component::from(&self.remote))
+
            .join(refname!("cobs"))
+
            .join(Component::from(typename))
+
            .join(Component::from(object_id))
+
    }
+
}
+

+
impl<'a> Store for Drafts<'a> {}
+

+
impl<'a> change::Storage for Drafts<'a> {
+
    type StoreError = <Storage as change::Storage>::StoreError;
+
    type LoadError = <Storage as change::Storage>::LoadError;
+

+
    type ObjectId = <Storage as change::Storage>::ObjectId;
+
    type Parent = <Storage as change::Storage>::Parent;
+
    type Signatures = <Storage as change::Storage>::Signatures;
+

+
    fn store<G>(
+
        &self,
+
        resource: Option<Self::Parent>,
+
        related: Vec<Self::Parent>,
+
        signer: &G,
+
        template: change::Template<Self::ObjectId>,
+
    ) -> Result<
+
        change::store::Entry<Self::Parent, Self::ObjectId, Self::Signatures>,
+
        Self::StoreError,
+
    >
+
    where
+
        G: crypto::Signer,
+
    {
+
        self.inner.store(resource, related, signer, template)
+
    }
+

+
    fn merge<G>(
+
        &self,
+
        tips: Vec<Self::ObjectId>,
+
        signer: &G,
+
        type_name: crate::TypeName,
+
        message: String,
+
    ) -> Result<
+
        change::store::MergeEntry<Self::ObjectId, Self::ObjectId, Self::Signatures>,
+
        Self::StoreError,
+
    >
+
    where
+
        G: crypto::Signer,
+
    {
+
        self.inner.merge(tips, signer, type_name, message)
+
    }
+

+
    fn load(
+
        &self,
+
        id: Self::ObjectId,
+
    ) -> Result<
+
        change::store::ChangeEntry<Self::Parent, Self::ObjectId, Self::Signatures>,
+
        Self::LoadError,
+
    > {
+
        self.inner.load(id)
+
    }
+

+
    fn parents_of(&self, id: &git_ext::Oid) -> Result<Vec<git_ext::Oid>, Self::LoadError> {
+
        self.inner.parents_of(id)
+
    }
+
}
+

+
impl<'a> object::Storage for Drafts<'a> {
+
    type ObjectsError = error::Objects;
+
    type TypesError = error::Objects;
+
    type UpdateError = git2::Error;
+
    type RemoveError = git2::Error;
+

+
    fn objects(
+
        &self,
+
        typename: &crate::TypeName,
+
        object_id: &ObjectId,
+
    ) -> Result<object::Objects, Self::ObjectsError> {
+
        let glob = format!("refs/rad/*/cobs/{typename}/{object_id}");
+
        let mut remotes = self
+
            .inner
+
            .raw
+
            .references_glob(&glob)?
+
            .map(|r| {
+
                r.map_err(error::Objects::from)
+
                    .and_then(|r| Reference::try_from(r).map_err(error::Objects::from))
+
            })
+
            .collect::<Result<Vec<_>, _>>()?;
+
        let draft_ref = self.refstring(typename, object_id);
+
        if let Ok(draft_tip) = self.inner.raw.refname_to_id(draft_ref.as_str()) {
+
            remotes.push(Reference {
+
                name: draft_ref,
+
                target: Commit {
+
                    id: draft_tip.into(),
+
                },
+
            });
+
        }
+
        Ok(remotes.into())
+
    }
+

+
    fn types(
+
        &self,
+
        typename: &crate::TypeName,
+
    ) -> Result<BTreeMap<ObjectId, object::Objects>, Self::TypesError> {
+
        self.inner.types(typename)
+
    }
+

+
    fn update(
+
        &self,
+
        _identifier: &PublicKey,
+
        typename: &crate::TypeName,
+
        object_id: &ObjectId,
+
        entry: &crate::EntryId,
+
    ) -> Result<(), Self::UpdateError> {
+
        let draft_ref = self.refstring(typename, object_id);
+
        self.inner
+
            .raw
+
            .reference(draft_ref.as_str(), (*entry).into(), true, "new change")?;
+
        Ok(())
+
    }
+

+
    fn remove(
+
        &self,
+
        _identifier: &PublicKey,
+
        typename: &crate::TypeName,
+
        object_id: &ObjectId,
+
    ) -> Result<(), Self::RemoveError> {
+
        let draft_ref = self.refstring(typename, object_id);
+
        self.inner
+
            .raw
+
            .find_reference(draft_ref.as_str())?
+
            .delete()?;
+

+
        Ok(())
+
    }
+
}
modified radicle-cob/src/tests.rs
@@ -4,12 +4,15 @@ use crypto::test::signer::MockSigner;
use crypto::{PublicKey, Signer};
use git_ext::ref_format::{refname, Component, RefString};
use nonempty::{nonempty, NonEmpty};
+
use pretty_assertions::{assert_eq, assert_ne};
use qcheck::Arbitrary;

+
use crate::object::collaboration;
use crate::{
    create, get, list, object, test::arbitrary::Invalid, update, Create, Entry, ObjectId, TypeName,
    Update, Updated, Version,
};
+
use crate::{merge, Merge, Merged};

use super::test;

@@ -155,6 +158,96 @@ fn update_cob() {
}

#[test]
+
fn merge_cob() {
+
    let storage = test::Storage::new();
+
    let signer = gen::<MockSigner>(1);
+
    let terry = test::Person::new(&storage, "terry", *signer.public_key()).unwrap();
+
    let proj = test::Project::new(&storage, "discworld", *signer.public_key()).unwrap();
+
    let proj = test::RemoteProject {
+
        project: proj,
+
        person: terry,
+
    };
+
    let typename = "xyz.rad.issue".parse::<TypeName>().unwrap();
+
    let cob = create::<NonEmpty<Entry>, _, _>(
+
        &storage,
+
        &signer,
+
        Some(proj.project.content_id),
+
        vec![],
+
        signer.public_key(),
+
        Create {
+
            contents: nonempty!(Vec::new()),
+
            type_name: typename.clone(),
+
            message: "creating xyz.rad.issue".to_string(),
+
            embeds: vec![],
+
            version: Version::default(),
+
        },
+
    )
+
    .unwrap();
+

+
    let drafts = test::Drafts::new(&storage, *signer.public_key());
+
    let Updated { .. } = update::<NonEmpty<Entry>, _, _>(
+
        &drafts,
+
        &signer,
+
        Some(proj.project.content_id),
+
        vec![],
+
        signer.public_key(),
+
        Update {
+
            changes: nonempty!(b"issue 1".to_vec()),
+
            object_id: *cob.id(),
+
            type_name: typename.clone(),
+
            embeds: vec![],
+
            message: "commenting xyz.rad.issue".to_string(),
+
        },
+
    )
+
    .unwrap();
+

+
    let Updated { object, .. } = update::<NonEmpty<Entry>, _, _>(
+
        &storage,
+
        &signer,
+
        Some(proj.project.content_id),
+
        vec![],
+
        signer.public_key(),
+
        Update {
+
            changes: nonempty!(b"issue bar".to_vec()),
+
            object_id: *cob.id(),
+
            type_name: typename.clone(),
+
            embeds: vec![],
+
            message: "commenting xyz.rad.issue".to_string(),
+
        },
+
    )
+
    .unwrap();
+

+
    assert_ne!(
+
        get::<NonEmpty<Entry>, _>(&drafts, &typename, cob.id())
+
            .unwrap()
+
            .expect("BUG: draft cob was missing"),
+
        get(&storage, &typename, object.id())
+
            .unwrap()
+
            .expect("BUG: cob was missing")
+
    );
+

+
    let Merged { object, .. } = merge::<NonEmpty<Entry>, _, _, _>(
+
        &collaboration::Draft::new(&drafts),
+
        &collaboration::Published::new(&storage),
+
        &signer,
+
        signer.public_key(),
+
        Merge {
+
            object_id: *cob.id(),
+
            type_name: typename.clone(),
+
            message: "commenting xyz.rad.issue".to_string(),
+
        },
+
    )
+
    .unwrap();
+

+
    assert_eq!(
+
        object,
+
        get(&storage, &typename, object.id())
+
            .unwrap()
+
            .expect("BUG: cob was missing")
+
    )
+
}
+

+
#[test]
fn traverse_cobs() {
    let storage = test::Storage::new();
    let neil_signer = gen::<MockSigner>(2);
@@ -211,7 +304,7 @@ fn traverse_cobs() {
    )
    .unwrap();

-
    let root = object.history.root().id;
+
    let root = *object.history.root().id();
    // traverse over the history and filter by changes that were only authorized by terry
    let contents = object
        .history()
modified radicle-dag/src/lib.rs
@@ -31,6 +31,32 @@ impl<K, V> Node<K, V> {
            dependents: BTreeSet::new(),
        }
    }
+

+
    /// Map a function over the `Node` value.
+
    pub fn map_value<U, F>(self, f: F) -> Node<K, U>
+
    where
+
        F: FnOnce(V) -> U,
+
    {
+
        Node {
+
            key: self.key,
+
            value: f(self.value),
+
            dependencies: self.dependencies,
+
            dependents: self.dependents,
+
        }
+
    }
+
}
+

+
impl<K, V> Node<K, Option<V>> {
+
    /// Given a [`Node`] with an optional value, peel the `Option` to get an
+
    /// optional `Node<K, V>`.
+
    pub fn transpose_value(self) -> Option<Node<K, V>> {
+
        self.value.map(|value| Node {
+
            key: self.key,
+
            value,
+
            dependencies: self.dependencies,
+
            dependents: self.dependents,
+
        })
+
    }
}

impl<K, V> Borrow<V> for &Node<K, V> {
modified radicle/src/cob.rs
@@ -20,7 +20,7 @@ pub use radicle_cob::{
    CollaborativeObject, Contents, Create, Embed, Entry, Evaluate, History, Manifest, ObjectId,
    Store, TypeName, Update, Updated, Version,
};
-
pub use radicle_cob::{create, get, git, list, remove, update};
+
pub use radicle_cob::{create, get, git, list, merge, remove, update};

/// The exact identifier for a particular COB.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
modified radicle/src/cob/patch.rs
@@ -3450,6 +3450,153 @@ mod test {
    }

    #[test]
+
    fn test_draft_patch_review() {
+
        use crate::storage::git::cob::DraftStore;
+

+
        let alice = test::setup::Node::default();
+
        let repo = alice.project();
+
        let branch = repo
+
            .checkout()
+
            .branch_with([("README.md", b"Hello, World!")]);
+
        let mut patches = Cache::no_cache(&*repo).unwrap();
+
        let mut patch = patches
+
            .create(
+
                "My first patch",
+
                "Blah blah blah.",
+
                MergeTarget::Delegates,
+
                branch.base,
+
                branch.oid,
+
                &[],
+
                &alice.signer,
+
            )
+
            .unwrap();
+
        let patch_id = patch.id;
+

+
        let draft = DraftStore::new(&repo.repo, *alice.signer.public_key());
+
        let mut draft_cache = Cache::no_cache(&draft).unwrap();
+
        let mut draft_patch = draft_cache.get_mut(&patch_id).unwrap();
+
        let review_id = draft_patch
+
            .review(
+
                RevisionId(*patch_id),
+
                Some(Verdict::Reject),
+
                Some("No good".to_string()),
+
                vec![],
+
                &alice.signer,
+
            )
+
            .unwrap();
+
        draft_patch
+
            .review_comment(
+
                review_id,
+
                "'Hello, World!' is such a cliché",
+
                None,
+
                None,
+
                [],
+
                &alice.signer,
+
            )
+
            .unwrap();
+

+
        // Check that the reviews are present before publishing
+
        {
+
            let mut reviews = draft_patch.reviews_of(RevisionId(*patch_id));
+
            let review = reviews.next();
+
            assert!(
+
                review.is_some(),
+
                "expected a single draft review, but found none"
+
            );
+
            let (_, review) = review.unwrap();
+
            assert_eq!(review.comments().count(), 1);
+
        }
+

+
        let update = repo
+
            .checkout()
+
            .branch_with([("README.md", b"Hello, Radicle!")]);
+
        let _revision_id = patch
+
            .update("I've made changes.", branch.base, update.oid, &alice.signer)
+
            .unwrap();
+
        assert_eq!(patch.revisions().count(), 2);
+

+
        draft
+
            .publish::<Patch, _>(super::TYPENAME.clone(), &patch_id, &alice.signer)
+
            .unwrap();
+

+
        patch.reload().unwrap();
+
        assert_eq!(patch.revisions().count(), 2);
+
        let mut reviews = patch.reviews_of(RevisionId(*patch_id));
+
        let review = reviews.next();
+
        assert!(review.is_some(), "expected a single review, but found none");
+
        let (_, review) = review.unwrap();
+
        assert_eq!(review.comments().count(), 1);
+
    }
+

+
    #[test]
+
    fn test_draft_patch_review_redacted_revision() {
+
        use crate::storage::git::cob::DraftStore;
+

+
        let alice = test::setup::Node::default();
+
        let repo = alice.project();
+
        let branch = repo
+
            .checkout()
+
            .branch_with([("README.md", b"Hello, World!")]);
+
        let mut patches = Cache::no_cache(&*repo).unwrap();
+
        let mut patch = patches
+
            .create(
+
                "My first patch",
+
                "Blah blah blah.",
+
                MergeTarget::Delegates,
+
                branch.base,
+
                branch.oid,
+
                &[],
+
                &alice.signer,
+
            )
+
            .unwrap();
+
        let patch_id = patch.id;
+

+
        let update = repo
+
            .checkout()
+
            .branch_with([("README.md", b"Hello, Radicle!")]);
+
        let revision_id = patch
+
            .update("I've made changes.", branch.base, update.oid, &alice.signer)
+
            .unwrap();
+
        assert_eq!(patch.revisions().count(), 2);
+

+
        let draft = DraftStore::new(&repo.repo, *alice.signer.public_key());
+
        let mut draft_cache = Cache::no_cache(&draft).unwrap();
+
        let mut draft_patch = draft_cache.get_mut(&patch_id).unwrap();
+
        let review_id = draft_patch
+
            .review(
+
                revision_id,
+
                Some(Verdict::Reject),
+
                Some("No good".to_string()),
+
                vec![],
+
                &alice.signer,
+
            )
+
            .unwrap();
+
        draft_patch
+
            .review_comment(
+
                review_id,
+
                "'Hello, World!' is such a cliché",
+
                None,
+
                None,
+
                [],
+
                &alice.signer,
+
            )
+
            .unwrap();
+

+
        // Revision gets redacted before the review gets published
+
        patch.redact(revision_id, &alice.signer).unwrap();
+

+
        draft
+
            .publish::<Patch, _>(super::TYPENAME.clone(), &patch_id, &alice.signer)
+
            .unwrap();
+

+
        patch.reload().unwrap();
+
        assert_eq!(patch.revisions().count(), 1);
+
        let mut reviews = patch.reviews_of(revision_id);
+
        let review = reviews.next();
+
        assert!(review.is_none());
+
    }
+

+
    #[test]
    fn test_json() {
        use serde_json::json;

modified radicle/src/cob/test.rs
@@ -118,7 +118,7 @@ where
            related: vec![],
            manifest,
        };
-
        self.history.extend(change);
+
        self.history.extend(change.into());

        oid
    }
modified radicle/src/storage/git/cob.rs
@@ -50,6 +50,15 @@ pub enum TypesError {
    RefFormat(#[from] git::fmt::Error),
}

+
#[derive(Debug, Error)]
+
#[error("failed to check if '{typename}/{object}' is in progress: {err}")]
+
pub struct InProgressError {
+
    typename: cob::TypeName,
+
    object: ObjectId,
+
    #[source]
+
    err: git::raw::Error,
+
}
+

impl cob::Store for Repository {}

impl change::Storage for Repository {
@@ -73,7 +82,23 @@ impl change::Storage for Repository {
        self.backend.store(authority, parents, signer, spec)
    }

-
    fn load(&self, id: Self::ObjectId) -> Result<cob::Entry, Self::LoadError> {
+
    fn merge<G>(
+
        &self,
+
        tips: Vec<Self::ObjectId>,
+
        signer: &G,
+
        type_name: radicle_cob::TypeName,
+
        message: String,
+
    ) -> Result<
+
        change::store::MergeEntry<Self::Parent, Self::ObjectId, Self::Signatures>,
+
        Self::StoreError,
+
    >
+
    where
+
        G: crypto::Signer,
+
    {
+
        change::Storage::merge(&self.backend, tips, signer, type_name, message)
+
    }
+

+
    fn load(&self, id: Self::ObjectId) -> Result<cob::ChangeEntry, Self::LoadError> {
        self.backend.load(id)
    }

@@ -190,6 +215,73 @@ impl<'a, R> DraftStore<'a, R> {
    }
}

+
impl<'a, R> DraftStore<'a, R>
+
where
+
    R: cob::Store,
+
    R: storage::WriteRepository + cob::object::Storage,
+
{
+
    // TODO(finto): this might need to be exposed via the cache too
+
    /// Publishes the COB draft, for the given `id`, to the published reference.
+
    /// That is it performs a merge of the two references:
+
    /// ```
+
    /// refs/namespaces/<remote>/refs/cobs/<typename>/<object_id>
+
    /// refs/namespaces/<remote>/refs/drafts/cobs/<typename>/<object_id>
+
    /// ```
+
    pub fn publish<T, G>(
+
        &self,
+
        typename: cob::TypeName,
+
        object_id: &ObjectId,
+
        signer: &G,
+
    ) -> Result<cob::CollaborativeObject<T>, cob::object::collaboration::error::Merge>
+
    where
+
        T: cob::Evaluate<Self>,
+
        T: cob::Evaluate<R>,
+
        G: crypto::Signer,
+
    {
+
        use cob::object::collaboration::{Draft, Published};
+

+
        let message = format!("publish draft {typename}/{object_id}");
+
        let cob::Merged { object, .. } = cob::merge::<T, Self, R, _>(
+
            &Draft::new(self),
+
            &Published::new(self.repo),
+
            signer,
+
            &self.remote,
+
            cob::object::Merge {
+
                type_name: typename,
+
                object_id: *object_id,
+
                message,
+
            },
+
        )?;
+
        Ok(object)
+
    }
+
}
+

+
impl<'a, R> DraftStore<'a, R>
+
where
+
    R: ReadRepository,
+
{
+
    /// Check if there is a draft in progress for the given `typename` and `object_id`.
+
    pub fn in_progress(
+
        &self,
+
        typename: &cob::TypeName,
+
        object_id: &ObjectId,
+
    ) -> Result<bool, InProgressError> {
+
        let draft_ref = git::refs::storage::draft::cob(&self.remote, typename, object_id);
+
        match self
+
            .repo
+
            .reference_oid(&self.remote, &draft_ref.strip_namespace())
+
        {
+
            Ok(_) => Ok(true),
+
            Err(e) if e.code() == git::raw::ErrorCode::NotFound => Ok(false),
+
            Err(e) => Err(InProgressError {
+
                typename: typename.clone(),
+
                object: *object_id,
+
                err: e,
+
            }),
+
        }
+
    }
+
}
+

impl<'a, R: storage::WriteRepository> cob::Store for DraftStore<'a, R> {}

impl<'a, R: storage::WriteRepository> change::Storage for DraftStore<'a, R> {
@@ -213,7 +305,23 @@ impl<'a, R: storage::WriteRepository> change::Storage for DraftStore<'a, R> {
        self.repo.raw().store(authority, parents, signer, spec)
    }

-
    fn load(&self, id: Self::ObjectId) -> Result<cob::Entry, Self::LoadError> {
+
    fn merge<G>(
+
        &self,
+
        tips: Vec<Self::ObjectId>,
+
        signer: &G,
+
        type_name: radicle_cob::TypeName,
+
        message: String,
+
    ) -> Result<
+
        change::store::MergeEntry<Self::Parent, Self::ObjectId, Self::Signatures>,
+
        Self::StoreError,
+
    >
+
    where
+
        G: crypto::Signer,
+
    {
+
        change::Storage::merge(self.repo.raw(), tips, signer, type_name, message)
+
    }
+

+
    fn load(&self, id: Self::ObjectId) -> Result<cob::ChangeEntry, Self::LoadError> {
        self.repo.raw().load(id)
    }

modified radicle/src/test/storage.rs
@@ -428,7 +428,7 @@ impl radicle_cob::change::Storage for MockRepository {
        &self,
        _id: Self::ObjectId,
    ) -> Result<
-
        radicle_cob::change::store::Entry<Self::Parent, Self::ObjectId, Self::Signatures>,
+
        radicle_cob::change::store::ChangeEntry<Self::Parent, Self::ObjectId, Self::Signatures>,
        Self::LoadError,
    > {
        todo!()
@@ -437,4 +437,20 @@ impl radicle_cob::change::Storage for MockRepository {
    fn parents_of(&self, _id: &Oid) -> Result<Vec<Oid>, Self::LoadError> {
        todo!()
    }
+

+
    fn merge<G>(
+
        &self,
+
        _tips: Vec<Self::ObjectId>,
+
        _signer: &G,
+
        _type_name: radicle_cob::TypeName,
+
        _message: String,
+
    ) -> Result<
+
        radicle_cob::change::store::MergeEntry<Self::Parent, Self::ObjectId, Self::Signatures>,
+
        Self::StoreError,
+
    >
+
    where
+
        G: crypto::Signer,
+
    {
+
        todo!()
+
    }
}