Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
cli: Code review in the CLI (Part 3.)
Merged did:key:z6MksFqX...wzpT opened 1 year ago

We now write the comments to a draft space in the repo, and clean up all the builder code to be better organized.

8 files changed +249 -93 81a3bc3d 74336f96
modified radicle-cli/examples/rad-cob-show.md
@@ -113,6 +113,7 @@ $ rad cob show --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch
  "merges": {},
  "revisions": {
    "d1f7f869fde9fac19c1779c4c2e77e8361333f91": {
+
      "id": "d1f7f869fde9fac19c1779c4c2e77e8361333f91",
      "author": {
        "id": "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
      },
modified radicle-cli/examples/rad-review-by-hunk.md
@@ -125,7 +125,7 @@ $ rad patch review --patch --accept --hunk 1 7a2ac7e2841cc1e7394f99f107555a499b1
│ @@ -1,1 +0,0 @@      │
│ 1          - *.draft │
╰──────────────────────╯
-
✓ Updated review tree to a5fccf0e977225ff13c3f74c43faf4cb679bf835
+
✓ Updated brain to a5fccf0e977225ff13c3f74c43faf4cb679bf835
```
```
$ rad patch review --patch --accept --hunk 1 7a2ac7e2841cc1e7394f99f107555a499b1d3f23 --no-announce
@@ -136,7 +136,7 @@ $ rad patch review --patch --accept --hunk 1 7a2ac7e2841cc1e7394f99f107555a499b1
│ @@ -0,0 +1,1 @@                                          │
│      1     + All food is served as-is, with no warranty! │
╰──────────────────────────────────────────────────────────╯
-
✓ Updated review tree to 2cdb82ea726e64d3b52847c7699d0d4759198f5c
+
✓ Updated brain to 2cdb82ea726e64d3b52847c7699d0d4759198f5c
```
```
$ rad patch review --patch --accept -U3 --hunk 1 7a2ac7e2841cc1e7394f99f107555a499b1d3f23 --no-announce
@@ -152,7 +152,7 @@ $ rad patch review --patch --accept -U3 --hunk 1 7a2ac7e2841cc1e7394f99f107555a4
│ 4    5       Mac & Cheese   │
│ 5    6                      │
╰─────────────────────────────╯
-
✓ Updated review tree to d4aecbb859a802a3215def0b538358bf63593953
+
✓ Updated brain to d4aecbb859a802a3215def0b538358bf63593953
```
```
$ rad patch review --patch --accept -U3 --hunk 1 7a2ac7e2841cc1e7394f99f107555a499b1d3f23 --no-announce
@@ -168,7 +168,7 @@ $ rad patch review --patch --accept -U3 --hunk 1 7a2ac7e2841cc1e7394f99f107555a4
│      15    + French Fries!            │
│      16    + Garlic Green Beans       │
╰───────────────────────────────────────╯
-
✓ Updated review tree to 59cee720b0642b1491b241400912b35926a76c3f
+
✓ Updated brain to 59cee720b0642b1491b241400912b35926a76c3f
```

```
@@ -177,7 +177,7 @@ $ rad patch review --patch --accept --hunk 1 7a2ac7e2841cc1e7394f99f107555a499b1
╭────────────────────────────────────────────────────╮
│ INSTRUCTIONS.txt -> notes/INSTRUCTIONS.txt ❲moved❳ │
╰────────────────────────────────────────────────────╯
-
✓ Updated review tree to 3effc8f6462fa2573697072245e57708c4dcbe62
+
✓ Updated brain to 3effc8f6462fa2573697072245e57708c4dcbe62
```

```
modified radicle-cli/src/commands/patch/review.rs
@@ -89,7 +89,7 @@ pub fn run(
                .minimal(true)
                .context_lines(unified as u32);

-
            builder::ReviewBuilder::new(patch_id, *profile.id(), repository)
+
            builder::ReviewBuilder::new(patch_id, signer, repository)
                .hunk(hunk)
                .verdict(verdict)
                .run(revision, &mut opts)?;
modified radicle-cli/src/commands/patch/review/builder.rs
@@ -18,11 +18,12 @@ use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{fmt, io};

+
use radicle::cob;
use radicle::cob::patch::{PatchId, Revision, Verdict};
use radicle::cob::{CodeLocation, CodeRange};
use radicle::git;
use radicle::prelude::*;
-
use radicle::storage::git::Repository;
+
use radicle::storage::git::{cob::DraftStore, Repository};
use radicle_git_ext::Oid;
use radicle_surf::diff::*;
use radicle_term::{Element, VStack};
@@ -387,6 +388,16 @@ impl ReviewQueue {
    }
}

+
impl From<Diff> for ReviewQueue {
+
    fn from(diff: Diff) -> Self {
+
        let mut queue = Self::default();
+
        for file in diff.into_files() {
+
            queue.add_file(file);
+
        }
+
        queue
+
    }
+
}
+

impl std::ops::Deref for ReviewQueue {
    type Target = VecDeque<(usize, ReviewItem)>;

@@ -410,6 +421,7 @@ impl Iterator for ReviewQueue {
}

/// Builds a review for a single file.
+
/// Adjusts line deltas when a hunk is ignored.
pub struct FileReviewBuilder {
    header: FileHeader,
    delta: i32,
@@ -438,12 +450,7 @@ impl FileReviewBuilder {
        }
    }

-
    fn apply_item<'a>(
-
        &mut self,
-
        item: ReviewItem,
-
        brain: &mut git::raw::Tree<'a>,
-
        repo: &'a git::raw::Repository,
-
    ) -> Result<(), Error> {
+
    fn item_diff(&mut self, item: ReviewItem) -> Result<git::raw::Diff, Error> {
        let mut buf = Vec::new();
        let mut writer = unified_diff::Writer::new(&mut buf);
        writer.encode(&self.header)?;
@@ -462,22 +469,111 @@ impl FileReviewBuilder {
        }
        drop(writer);

-
        let diff = git::raw::Diff::from_buffer(&buf)?;
-
        let mut index = repo.apply_to_tree(brain, &diff, None)?;
-
        let brain_oid = index.write_tree_to(repo)?;
+
        git::raw::Diff::from_buffer(&buf).map_err(Error::from)
+
    }
+
}
+

+
/// Represents the reviewer's brain, ie. what they have seen or not seen in terms
+
/// of changes introduced by a patch.
+
pub struct Brain<'a> {
+
    /// Where the review draft is being stored.
+
    refname: git::Namespaced<'a>,
+
    /// The commit pointed to by the ref.
+
    head: git::raw::Commit<'a>,
+
    /// The tree of accepted changes pointed to by the head commit.
+
    accepted: git::raw::Tree<'a>,
+
}
+

+
impl<'a> Brain<'a> {
+
    /// Create a new brain in the repository.
+
    fn new(
+
        patch: PatchId,
+
        remote: &NodeId,
+
        base: git::raw::Commit,
+
        repo: &'a git::raw::Repository,
+
    ) -> Result<Self, git::raw::Error> {
+
        let refname = Self::refname(&patch, remote);
+
        let author = repo.signature()?;
+
        let oid = repo.commit(
+
            Some(refname.as_str()),
+
            &author,
+
            &author,
+
            &format!("Review for {patch}"),
+
            &base.tree()?,
+
            // TODO: Verify this is necessary, shouldn't matter.
+
            &[&base],
+
        )?;
+
        let head = repo.find_commit(oid)?;
+
        let tree = head.tree()?;
+

+
        Ok(Self {
+
            refname,
+
            head,
+
            accepted: tree,
+
        })
+
    }
+

+
    /// Return the content identifier of this brain. This represents the state of the
+
    /// accepted hunks, ie. the git tree.
+
    fn cid(&self) -> Oid {
+
        self.accepted.id().into()
+
    }
+

+
    /// Load an existing brain from the repository.
+
    fn load(
+
        patch: PatchId,
+
        remote: &NodeId,
+
        repo: &'a git::raw::Repository,
+
    ) -> Result<Self, git::raw::Error> {
+
        // TODO: Validate this leads to correct UX for potentially abandoned drafts on
+
        // past revisions.
+
        let refname = Self::refname(&patch, remote);
+
        let head = repo.find_reference(&refname)?.peel_to_commit()?;
+
        let tree = head.tree()?;
+

+
        Ok(Self {
+
            refname,
+
            head,
+
            accepted: tree,
+
        })
+
    }

-
        *brain = repo.find_tree(brain_oid)?;
+
    /// Accept changes to the brain.
+
    fn accept(
+
        &mut self,
+
        diff: git::raw::Diff,
+
        repo: &'a git::raw::Repository,
+
    ) -> Result<(), git::raw::Error> {
+
        let mut index = repo.apply_to_tree(&self.accepted, &diff, None)?;
+
        let accepted = index.write_tree_to(repo)?;
+
        self.accepted = repo.find_tree(accepted)?;
+

+
        // Update review with new brain.
+
        let head = self.head.amend(
+
            Some(&self.refname),
+
            None,
+
            None,
+
            None,
+
            None,
+
            Some(&self.accepted),
+
        )?;
+
        self.head = repo.find_commit(head)?;

        Ok(())
    }
+

+
    /// Get the brain's refname given the patch and remote.
+
    fn refname(patch: &PatchId, remote: &NodeId) -> git::Namespaced<'a> {
+
        git::refs::storage::draft::review(remote, patch)
+
    }
}

/// Builds a patch review interactively, across multiple files.
-
pub struct ReviewBuilder<'a> {
+
pub struct ReviewBuilder<'a, G> {
    /// Patch being reviewed.
    patch_id: PatchId,
-
    /// Where the review draft is being stored.
-
    refname: git::Namespaced<'a>,
+
    /// Signer.
+
    signer: G,
    /// Stored copy of repository.
    repo: &'a Repository,
    /// Single hunk review.
@@ -486,14 +582,12 @@ pub struct ReviewBuilder<'a> {
    verdict: Option<Verdict>,
}

-
impl<'a> ReviewBuilder<'a> {
+
impl<'a, G: Signer> ReviewBuilder<'a, G> {
    /// Create a new review builder.
-
    pub fn new(patch_id: PatchId, nid: NodeId, repo: &'a Repository) -> Self {
+
    pub fn new(patch_id: PatchId, signer: G, repo: &'a Repository) -> Self {
        Self {
            patch_id,
-
            // TODO: Validate this leads to correct UX for potentially abandoned drafts on
-
            // past revisions.
-
            refname: git::refs::storage::draft::review(&nid, &patch_id),
+
            signer,
            repo,
            hunk: None,
            verdict: None,
@@ -515,8 +609,8 @@ impl<'a> ReviewBuilder<'a> {
    /// Run the review builder for the given revision.
    pub fn run(self, revision: &Revision, opts: &mut git::raw::DiffOptions) -> anyhow::Result<()> {
        let repo = self.repo.raw();
+
        let signer = &self.signer;
        let base = repo.find_commit((*revision.base()).into())?;
-
        let author = repo.signature()?;
        let patch_id = self.patch_id;
        let tree = {
            let commit = repo.find_commit(revision.head().into())?;
@@ -530,38 +624,46 @@ impl<'a> ReviewBuilder<'a> {
        } else {
            Box::new(io::stderr().lock())
        };
-
        let mut review = if let Ok(c) = self.current() {
+
        let mut brain = if let Ok(b) = Brain::load(self.patch_id, signer.public_key(), repo) {
            term::success!(
                "Loaded existing review {} for patch {}",
-
                term::format::secondary(term::format::parens(term::format::oid(c.id()))),
+
                term::format::secondary(term::format::parens(term::format::oid(b.head.id()))),
                term::format::tertiary(&patch_id)
            );
-
            c
+
            b
        } else {
-
            let oid = repo.commit(
-
                Some(self.refname.as_str()),
-
                &author,
-
                &author,
-
                &format!("Review {patch_id}"),
-
                &base.tree()?,
-
                // TODO: Verify this is necessary, shouldn't matter.
-
                &[&base],
-
            )?;
-
            repo.find_commit(oid)?
+
            Brain::new(self.patch_id, signer.public_key(), base, repo)?
        };
-
        let mut brain = review.tree()?;
-
        let mut queue = ReviewQueue::default();
-
        let diff = self.diff(&brain, &tree, repo, opts)?;
+
        let diff = self.diff(&brain.accepted, &tree, repo, opts)?;
+
        let drafts = DraftStore::new(*signer.public_key(), self.repo).with(
+
            signer.public_key(),
+
            &cob::patch::TYPENAME,
+
            &patch_id,
+
        )?;
+
        let mut patches = cob::patch::Cache::no_cache(&drafts)?;
+
        let mut patch = patches.get_mut(&patch_id)?;
+
        let mut queue = ReviewQueue::from(diff);

-
        // Build the review queue.
-
        for file in diff.into_files() {
-
            queue.add_file(file);
-
        }
        if queue.is_empty() {
            term::success!("All hunks have been reviewed");
            return Ok(());
        }

+
        let review = if let Some(r) = revision.review_by(signer.public_key()) {
+
            r.id()
+
        } else {
+
            patch.review(
+
                revision.id(),
+
                // This is amended before the review is finalized, if all hunks are
+
                // accepted. We can't set this to `None`, as that will be invalid without
+
                // a review summary.
+
                Some(Verdict::Reject),
+
                None,
+
                vec![],
+
                signer,
+
            )?
+
        };
+

        // File review for the current file. Starts out as `None` and is set on the first hunk.
        // Keeps track of deltas for hunk offsets.
        let mut file: Option<FileReviewBuilder> = None;
@@ -589,15 +691,12 @@ impl<'a> ReviewBuilder<'a> {
                // When a hunk is accepted, we convert it to unified diff format,
                // and apply it to the `brain`.
                Some(ReviewAction::Accept) => {
-
                    // Update brain with accepted hunk.
-
                    file.apply_item(item, &mut brain, repo)?;
-
                    // Update review with new brain.
-
                    let review_oid =
-
                        review.amend(Some(&self.refname), None, None, None, None, Some(&brain))?;
-
                    review = repo.find_commit(review_oid)?;
+
                    // Compute hunk diff and update brain by applying it.
+
                    let diff = file.item_diff(item)?;
+
                    brain.accept(diff, repo)?;

                    if self.hunk.is_some() {
-
                        term::success!("Updated review tree to {}", brain.id());
+
                        term::success!("Updated brain to {}", brain.cid());
                    }
                }
                Some(ReviewAction::Ignore) => {
@@ -609,12 +708,21 @@ impl<'a> ReviewBuilder<'a> {
                    let path = old.or(new);

                    if let (Some(hunk), Some((path, _))) = (item.hunk(), path) {
-
                        let mut builder = CommentBuilder::new(revision.head(), path.to_path_buf());
-
                        builder.edit(hunk)?;
-

-
                        let _comments = builder.comments();
-

-
                        queue.push_front((ix, item));
+
                        let builder = CommentBuilder::new(revision.head(), path.to_path_buf());
+
                        let comments = builder.edit(hunk)?;
+

+
                        patch.transaction("Review comments", signer, |tx| {
+
                            for comment in comments {
+
                                tx.review_comment(
+
                                    review,
+
                                    comment.body,
+
                                    Some(comment.location),
+
                                    None,   // Not a reply.
+
                                    vec![], // No embeds.
+
                                )?;
+
                            }
+
                            Ok(())
+
                        })?;
                    } else {
                        eprintln!(
                            "{}",
@@ -706,13 +814,6 @@ impl<'a> ReviewBuilder<'a> {
            Some(ReviewAction::Ignore)
        }
    }
-

-
    fn current(&self) -> Result<git::raw::Commit, git::raw::Error> {
-
        self.repo
-
            .raw()
-
            .find_reference(&self.refname)?
-
            .peel_to_commit()
-
    }
}

#[derive(Debug, PartialEq, Eq)]
@@ -751,7 +852,7 @@ impl CommentBuilder {
        }
    }

-
    fn edit(&mut self, hunk: &Hunk<Modification>) -> Result<&mut Self, Error> {
+
    fn edit(mut self, hunk: &Hunk<Modification>) -> Result<Vec<ReviewComment>, Error> {
        let mut input = String::new();
        for line in hunk.to_unified_string()?.lines() {
            writeln!(&mut input, "> {line}")?;
@@ -762,7 +863,7 @@ impl CommentBuilder {
            let header = HunkHeader::try_from(hunk)?;
            self.add_hunk(header, &output);
        }
-
        Ok(self)
+
        Ok(self.comments())
    }

    fn add_hunk(&mut self, hunk: HunkHeader, input: &str) -> &mut Self {
modified radicle/src/cob/cache.rs
@@ -47,7 +47,7 @@ pub struct Read;
#[derive(Clone)]
pub struct Write;

-
/// A file-backed database storing information about the network.
+
/// A file-backed database storing materialized COBs.
#[derive(Clone)]
pub struct Store<T> {
    pub(super) db: Arc<sql::ConnectionThreadSafe>,
modified radicle/src/cob/patch.rs
@@ -1,5 +1,6 @@
pub mod cache;

+
use std::collections::btree_map::Entry;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt;
use std::ops::Deref;
@@ -106,6 +107,9 @@ pub enum Error {
    /// Review is empty.
    #[error("empty review; verdict or summary not provided")]
    EmptyReview,
+
    /// Duplicate review.
+
    #[error("review {0} of {1} already exists by author {2}")]
+
    DuplicateReview(ReviewId, RevisionId, NodeId),
    /// Error loading the document payload.
    #[error("payload failed to load: {0}")]
    Payload(#[from] PayloadError),
@@ -521,7 +525,7 @@ impl Patch {
            t.and_then(|(rev_id, pk)| {
                if rev == rev_id {
                    self.revision(&rev_id)
-
                        .and_then(|r| r.review(&pk))
+
                        .and_then(|r| r.review_by(&pk))
                        .map(|r| (review_id, r))
                } else {
                    None
@@ -825,10 +829,12 @@ impl Patch {
                resolves,
            } => {
                debug_assert!(!self.revisions.contains_key(&entry));
+
                let id = RevisionId(entry);

                self.revisions.insert(
-
                    RevisionId(entry),
+
                    id,
                    Some(Revision::new(
+
                        id,
                        author.into(),
                        description,
                        base,
@@ -886,21 +892,25 @@ impl Patch {
                    return Err(Error::EmptyReview);
                }
                if let Some(rev) = rev {
-
                    // Nb. Applying two reviews by the same author is not allowed and
-
                    // results in the review being redacted.
-
                    rev.reviews.insert(
-
                        author,
-
                        Some(Review::new(
-
                            Author::new(author),
-
                            verdict,
-
                            summary.to_owned(),
-
                            labels,
-
                            timestamp,
-
                        )),
-
                    );
+
                    let id = ReviewId(entry);
+
                    // Nb. Applying two reviews by the same author is not allowed.
+
                    match rev.reviews.entry(author) {
+
                        Entry::Occupied(_) => {
+
                            return Err(Error::DuplicateReview(id, revision, author));
+
                        }
+
                        Entry::Vacant(e) => {
+
                            e.insert(Some(Review::new(
+
                                id,
+
                                Author::new(author),
+
                                verdict,
+
                                summary.to_owned(),
+
                                labels,
+
                                timestamp,
+
                            )));
+
                        }
+
                    }
                    // Update reviews index.
-
                    self.reviews
-
                        .insert(ReviewId(entry), Some((revision, author)));
+
                    self.reviews.insert(id, Some((revision, author)));
                }
            }
            Action::ReviewCommentReact {
@@ -1150,6 +1160,7 @@ impl store::Cob for Patch {
            return Err(Error::Init("the second action must be of type `edit`"));
        };
        let revision = Revision::new(
+
            RevisionId(op.id),
            op.author.into(),
            description,
            base,
@@ -1332,6 +1343,8 @@ mod lookup {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Revision {
+
    /// Revision identifier.
+
    pub(super) id: RevisionId,
    /// Author of the revision.
    pub(super) author: Author,
    /// Revision description.
@@ -1358,6 +1371,7 @@ pub struct Revision {

impl Revision {
    pub fn new(
+
        id: RevisionId,
        author: Author,
        description: String,
        base: git::Oid,
@@ -1368,6 +1382,7 @@ impl Revision {
        let description = Edit::new(*author.public_key(), description, timestamp, Vec::default());

        Self {
+
            id,
            author,
            description: NonEmpty::new(description),
            base,
@@ -1380,6 +1395,10 @@ impl Revision {
        }
    }

+
    pub fn id(&self) -> RevisionId {
+
        self.id
+
    }
+

    pub fn description(&self) -> &str {
        self.description.last().body.as_str()
    }
@@ -1439,7 +1458,7 @@ impl Revision {
    }

    /// Get a review by author.
-
    pub fn review(&self, author: &ActorId) -> Option<&Review> {
+
    pub fn review_by(&self, author: &ActorId) -> Option<&Review> {
        self.reviews.get(author).and_then(|o| o.as_ref())
    }
}
@@ -1559,6 +1578,8 @@ impl fmt::Display for Verdict {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Review {
+
    /// Review identifier.
+
    pub(super) id: ReviewId,
    /// Review author.
    pub(super) author: Author,
    /// Review verdict.
@@ -1580,6 +1601,7 @@ pub struct Review {

impl Review {
    pub fn new(
+
        id: ReviewId,
        author: Author,
        verdict: Option<Verdict>,
        summary: Option<String>,
@@ -1587,6 +1609,7 @@ impl Review {
        timestamp: Timestamp,
    ) -> Self {
        Self {
+
            id,
            author,
            verdict,
            summary,
@@ -1596,6 +1619,11 @@ impl Review {
        }
    }

+
    /// Review identifier.
+
    pub fn id(&self) -> ReviewId {
+
        self.id
+
    }
+

    /// Review author.
    pub fn author(&self) -> &Author {
        &self.author
@@ -2159,12 +2187,12 @@ where
        &mut self,
        revision: RevisionId,
        verdict: Option<Verdict>,
-
        comment: Option<String>,
+
        summary: Option<String>,
        labels: Vec<Label>,
        signer: &G,
    ) -> Result<ReviewId, Error> {
        self.transaction("Review", signer, |tx| {
-
            tx.review(revision, verdict, comment, labels)
+
            tx.review(revision, verdict, summary, labels)
        })
        .map(ReviewId)
    }
@@ -2891,7 +2919,7 @@ mod test {
        let (_, revision) = patch.latest();
        assert_eq!(revision.reviews.len(), 1);

-
        let review = revision.review(alice.signer.public_key()).unwrap();
+
        let review = revision.review_by(alice.signer.public_key()).unwrap();
        assert_eq!(review.verdict(), Some(Verdict::Accept));
        assert_eq!(review.summary(), Some("LGTM"));

@@ -3135,7 +3163,7 @@ mod test {
            .unwrap(); // Overwrite the comment.
                       //
        let (_, revision) = patch.latest();
-
        let review = revision.review(alice.signer.public_key()).unwrap();
+
        let review = revision.review_by(alice.signer.public_key()).unwrap();
        assert_eq!(review.verdict(), Some(Verdict::Reject));
        assert_eq!(review.summary(), Some("Whoops!"));
    }
@@ -3180,7 +3208,7 @@ mod test {
            .unwrap();

        let (_, revision) = patch.latest();
-
        let review = revision.review(alice.signer.public_key()).unwrap();
+
        let review = revision.review_by(alice.signer.public_key()).unwrap();
        let (_, comment) = review.comments().next().unwrap();

        assert_eq!(comment.body(), "I like these lines of code");
@@ -3216,7 +3244,7 @@ mod test {
        let id = patch.id;
        let patch = patches.get_mut(&id).unwrap();
        let (_, revision) = patch.latest();
-
        let review = revision.review(alice.signer.public_key()).unwrap();
+
        let review = revision.review_by(alice.signer.public_key()).unwrap();

        assert_eq!(review.summary(), None);
    }
modified radicle/src/cob/patch/cache.rs
@@ -712,7 +712,9 @@ mod tests {
        let oid = arbitrary::oid();
        let timestamp = env::local_time();
        let resolves = BTreeSet::new();
+
        let id = RevisionId::from(arbitrary::oid());
        let mut revision = Revision::new(
+
            id,
            Author { id: author },
            description,
            base,
@@ -730,7 +732,6 @@ mod tests {
        );
        let thread = Thread::new(arbitrary::oid(), comment);
        revision.discussion = thread;
-
        let id = RevisionId::from(arbitrary::oid());
        (id, revision)
    }

modified radicle/src/storage/git/cob.rs
@@ -182,6 +182,31 @@ pub struct DraftStore<'a, R> {
    repo: &'a R,
}

+
impl<'a, R: storage::WriteRepository> DraftStore<'a, R> {
+
    /// Create this draft store with an existing COB from storage, so that changes applied
+
    /// to this COB can evaluate properly. This creates a symbolic reference to the COB
+
    /// pointing to the public COB reference.
+
    pub fn with(
+
        self,
+
        remote: &RemoteId,
+
        typename: &cob::TypeName,
+
        id: &ObjectId,
+
    ) -> Result<Self, git::Error> {
+
        let target = git::refs::storage::cob(remote, typename, id);
+
        let name = git::refs::storage::draft::cob(remote, typename, id);
+
        let repo = self.repo.raw();
+

+
        repo.reference_symbolic(
+
            name.as_str(),
+
            target.as_str(),
+
            true, // The reference may already exist, overwrite it if so.
+
            format!("Link to COB {id} of type {typename}").as_str(),
+
        )?;
+

+
        Ok(self)
+
    }
+
}
+

impl<'a, R> DraftStore<'a, R> {
    pub fn new(remote: RemoteId, repo: &'a R) -> Self {
        Self { remote, repo }