Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Set patch status to `merged` automatically
Alexis Sellier committed 3 years ago
commit 33461e4be19d750852dd9c7bd49c1db849d228a4
parent 6b44955cd95c12f4bad9bbbb012086d7bdec871d
17 files changed +248 -81
modified radicle-cli/examples/workflow/5-patching-maintainer.md
@@ -76,7 +76,7 @@ Fast-forward
The patch is now merged and closed :).

```
-
$ rad patch
+
$ rad patch --merged
╭───────────────────────────────────────────────────────────────────────────────────────────────╮
│ Define power requirements a07ef77 R2 f6484e0 (flux-capacitor-power, master) ahead 3, behind 0 │
├───────────────────────────────────────────────────────────────────────────────────────────────┤
modified radicle-cli/src/commands/patch.rs
@@ -17,6 +17,7 @@ use std::ffi::OsString;

use anyhow::anyhow;

+
use radicle::cob::patch;
use radicle::cob::patch::PatchId;
use radicle::{prelude::*, Node};

@@ -34,7 +35,7 @@ pub const HELP: Help = Help {
Usage

    rad patch
-
    rad patch list
+
    rad patch list [--all|--merged|--open|--archived]
    rad patch show <id>
    rad patch open [<option>...]
    rad patch update <id> [<option>...]
@@ -48,7 +49,14 @@ Create/Update options
    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)
        --no-message           Leave the patch or revision comment message blank

-
Options
+
List options
+

+
        --all                  Show all patches, including merged and archived patches
+
        --archived             Show only archived patches
+
        --merged               Show only merged patches
+
        --open                 Show only open patches (default)
+

+
Other options

        --help                 Print help
"#,
@@ -83,7 +91,9 @@ pub enum Operation {
    Checkout {
        patch_id: Rev,
    },
-
    List,
+
    List {
+
        filter: Option<patch::State>,
+
    },
}

#[derive(Debug)]
@@ -107,6 +117,7 @@ impl Args for Options {
        let mut patch_id = None;
        let mut message = Message::default();
        let mut push = true;
+
        let mut filter = Some(patch::State::Open);

        while let Some(arg) = parser.next()? {
            match arg {
@@ -140,6 +151,20 @@ impl Args for Options {
                    push = false;
                }

+
                // List options.
+
                Long("all") => {
+
                    filter = None;
+
                }
+
                Long("archived") => {
+
                    filter = Some(patch::State::Archived);
+
                }
+
                Long("merged") => {
+
                    filter = Some(patch::State::Merged);
+
                }
+
                Long("open") => {
+
                    filter = Some(patch::State::Open);
+
                }
+

                // Common.
                Long("verbose") | Short('v') => {
                    verbose = true;
@@ -169,7 +194,7 @@ impl Args for Options {

        let op = match op.unwrap_or_default() {
            OperationName::Open => Operation::Open { message },
-
            OperationName::List => Operation::List,
+
            OperationName::List => Operation::List { filter },
            OperationName::Show => Operation::Show {
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch id must be provided"))?,
            },
@@ -210,8 +235,8 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        Operation::Open { ref message } => {
            create::run(&repository, &profile, &workdir, message.clone(), options)?;
        }
-
        Operation::List => {
-
            list::run(&repository, &profile, Some(workdir))?;
+
        Operation::List { filter } => {
+
            list::run(&repository, &profile, Some(workdir), filter)?;
        }
        Operation::Show { patch_id } => {
            let patch_id = patch_id.resolve(&repository.backend)?;
modified radicle-cli/src/commands/patch/list.rs
@@ -1,5 +1,6 @@
use anyhow::anyhow;

+
use radicle::cob::patch;
use radicle::cob::patch::{Patch, PatchId, Patches, Revision, Verdict};
use radicle::git;
use radicle::prelude::*;
@@ -16,17 +17,25 @@ pub fn run(
    repository: &Repository,
    profile: &Profile,
    workdir: Option<git::raw::Repository>,
+
    filter: Option<patch::State>,
) -> anyhow::Result<()> {
    let me = *profile.id();
    let patches = Patches::open(repository)?;
-
    let proposed = patches.proposed()?;
+
    let all = patches.all()?;

    // Patches the user authored.
    let mut own = Vec::new();
    // Patches other users authored.
    let mut other = Vec::new();

-
    for (id, patch, _) in proposed {
+
    for patch in all {
+
        let (id, patch, _) = patch?;
+

+
        if let Some(filter) = filter {
+
            if patch.state() != filter {
+
                continue;
+
            }
+
        }
        if patch.author().id().as_key() == &me {
            own.push((id, patch));
        } else {
modified radicle-httpd/src/api/v1/delegates.rs
@@ -101,6 +101,7 @@ mod routes {
                  "open": 1,
                  "draft": 0,
                  "archived": 0,
+
                  "merged": 0,
                },
                "issues": {
                  "open": 1,
modified radicle-httpd/src/api/v1/projects.rs
@@ -751,6 +751,7 @@ mod routes {
                  "open": 1,
                  "draft": 0,
                  "archived": 0,
+
                  "merged": 0,
                },
                "issues": {
                  "open": 1,
@@ -781,6 +782,7 @@ mod routes {
                 "open": 1,
                 "draft": 0,
                 "archived": 0,
+
                 "merged": 0,
               },
               "issues": {
                 "open": 1,
@@ -2267,7 +2269,7 @@ mod routes {
              },
              "title": "A new `hello world`",
              "description": "change `hello world` in README to something else",
-
              "state": { "status": "open" },
+
              "state": { "status": "merged" },
              "target": "delegates",
              "tags": [],
              "revisions": [
modified radicle/src/cob/identity.rs
@@ -16,7 +16,7 @@ use crate::{
        store::{self, FromHistory as _, Transaction},
    },
    identity::{doc::DocError, Did, Identity, IdentityError},
-
    prelude::Doc,
+
    prelude::{Doc, ReadRepository},
    storage::{git as storage, RemoteId, WriteRepository},
};

@@ -307,7 +307,11 @@ impl store::FromHistory for Proposal {
        &*TYPENAME
    }

-
    fn apply(&mut self, ops: impl IntoIterator<Item = Op>) -> Result<(), Self::Error> {
+
    fn apply<R: ReadRepository>(
+
        &mut self,
+
        ops: impl IntoIterator<Item = Op>,
+
        repo: &R,
+
    ) -> Result<(), Self::Error> {
        for op in ops {
            let id = op.id;
            let author = Author::new(op.author);
@@ -358,15 +362,17 @@ impl store::FromHistory for Proposal {
                }

                Action::Thread { revision, action } => match self.revisions.get_mut(&revision) {
-
                    Some(Redactable::Present(revision)) => {
-
                        revision.discussion.apply([cob::Op::new(
+
                    Some(Redactable::Present(revision)) => revision.discussion.apply(
+
                        [cob::Op::new(
                            op.id,
                            action,
                            op.author,
                            op.timestamp,
                            op.clock,
-
                        )])?
-
                    }
+
                            op.identity,
+
                        )],
+
                        repo,
+
                    )?,
                    Some(Redactable::Redacted) => return Err(ApplyError::Redacted(revision)),
                    None => return Err(ApplyError::Missing(revision)),
                },
@@ -578,7 +584,7 @@ impl<'a, 'g> ProposalMut<'a, 'g> {
        operations(&mut tx)?;
        let (ops, clock, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;

-
        self.proposal.apply(ops)?;
+
        self.proposal.apply(ops, self.store.as_ref())?;
        self.clock = clock;

        Ok(commit)
modified radicle/src/cob/issue.rs
@@ -16,7 +16,7 @@ use crate::cob::thread;
use crate::cob::thread::{CommentId, Thread};
use crate::cob::{store, ActorId, EntryId, ObjectId, TypeName};
use crate::crypto::Signer;
-
use crate::prelude::Did;
+
use crate::prelude::{Did, ReadRepository};
use crate::storage::git as storage;

/// Issue operation.
@@ -122,7 +122,11 @@ impl store::FromHistory for Issue {
        &*TYPENAME
    }

-
    fn apply(&mut self, ops: impl IntoIterator<Item = Op>) -> Result<(), Error> {
+
    fn apply<R: ReadRepository>(
+
        &mut self,
+
        ops: impl IntoIterator<Item = Op>,
+
        repo: &R,
+
    ) -> Result<(), Error> {
        for op in ops {
            match op.action {
                Action::Assign { add, remove } => {
@@ -148,13 +152,17 @@ impl store::FromHistory for Issue {
                    }
                }
                Action::Thread { action } => {
-
                    self.thread.apply([cob::Op::new(
-
                        op.id,
-
                        action,
-
                        op.author,
-
                        op.timestamp,
-
                        op.clock,
-
                    )])?;
+
                    self.thread.apply(
+
                        [cob::Op::new(
+
                            op.id,
+
                            action,
+
                            op.author,
+
                            op.timestamp,
+
                            op.clock,
+
                            op.identity,
+
                        )],
+
                        repo,
+
                    )?;
                }
            }
        }
@@ -389,7 +397,7 @@ impl<'a, 'g> IssueMut<'a, 'g> {
        operations(&mut tx)?;
        let (ops, clock, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;

-
        self.issue.apply(ops)?;
+
        self.issue.apply(ops, self.store.as_ref())?;
        self.clock = clock;

        Ok(commit)
modified radicle/src/cob/op.rs
@@ -6,6 +6,8 @@ use radicle_crdt::clock;
use radicle_crdt::clock::Lamport;
use radicle_crypto::PublicKey;

+
use crate::git;
+

/// The author of an [`Op`].
pub type ActorId = PublicKey;

@@ -34,6 +36,8 @@ pub struct Op<A> {
    pub clock: Lamport,
    /// Timestamp of this operation.
    pub timestamp: clock::Physical,
+
    /// Head of identity document committed to by this operation.
+
    pub identity: git::Oid,
}

impl<A: Eq> PartialOrd for Op<A> {
@@ -55,6 +59,7 @@ impl<A> Op<A> {
        author: ActorId,
        timestamp: impl Into<clock::Physical>,
        clock: Lamport,
+
        identity: git::Oid,
    ) -> Self {
        Self {
            id,
@@ -62,6 +67,7 @@ impl<A> Op<A> {
            author,
            clock,
            timestamp: timestamp.into(),
+
            identity,
        }
    }

@@ -80,6 +86,7 @@ where

    fn try_from(entry: &'a EntryWithClock) -> Result<Self, Self::Error> {
        let id = *entry.id();
+
        let identity = entry.resource();
        let ops = entry
            .changes()
            .map(|blob| {
@@ -90,6 +97,7 @@ where
                    author: *entry.actor(),
                    clock: entry.clock().into(),
                    timestamp: entry.timestamp().into(),
+
                    identity,
                };
                Ok::<_, Self::Error>(op)
            })
modified radicle/src/cob/patch.rs
@@ -23,6 +23,7 @@ use crate::cob::thread::Thread;
use crate::cob::{store, ActorId, EntryId, ObjectId, TypeName};
use crate::crypto::{PublicKey, Signer};
use crate::git;
+
use crate::identity::doc::DocError;
use crate::prelude::*;
use crate::storage::git as storage;

@@ -60,6 +61,9 @@ pub enum ApplyError {
    /// Error applying an op to the patch thread.
    #[error("thread apply failed: {0}")]
    Thread(#[from] thread::OpError),
+
    /// Error loading the identity document committed to by an operation.
+
    #[error("identity doc failed to load: {0}")]
+
    Doc(#[from] DocError),
}

/// Error updating or creating patches.
@@ -275,7 +279,11 @@ impl store::FromHistory for Patch {
        &*TYPENAME
    }

-
    fn apply(&mut self, ops: impl IntoIterator<Item = Op>) -> Result<(), ApplyError> {
+
    fn apply<R: ReadRepository>(
+
        &mut self,
+
        ops: impl IntoIterator<Item = Op>,
+
        repo: &R,
+
    ) -> Result<(), ApplyError> {
        for op in ops {
            let id = op.id;
            let author = Author::new(op.author);
@@ -376,6 +384,16 @@ impl store::FromHistory for Patch {
                            .into(),
                            op.clock,
                        );
+
                        let doc = repo.identity_doc_at(op.identity)?;
+

+
                        if revision
+
                            .merges()
+
                            .filter(|m| doc.is_delegate(&m.node))
+
                            .count()
+
                            >= doc.threshold
+
                        {
+
                            self.state.set(State::Merged, op.clock);
+
                        }
                    } else {
                        return Err(ApplyError::Missing(revision));
                    }
@@ -384,9 +402,17 @@ impl store::FromHistory for Patch {
                    // TODO(cloudhead): Make sure we can deal with redacted revisions which are added
                    // to out of order, like in the `Merge` case.
                    if let Some(Redactable::Present(revision)) = self.revisions.get_mut(&revision) {
-
                        revision
-
                            .discussion
-
                            .apply([cob::Op::new(op.id, action, op.author, timestamp, op.clock)])?;
+
                        revision.discussion.apply(
+
                            [cob::Op::new(
+
                                op.id,
+
                                action,
+
                                op.author,
+
                                timestamp,
+
                                op.clock,
+
                                op.identity,
+
                            )],
+
                            repo,
+
                        )?;
                    } else {
                        return Err(ApplyError::Missing(revision));
                    }
@@ -487,6 +513,7 @@ pub enum State {
    Open,
    Draft,
    Archived,
+
    Merged,
}

impl fmt::Display for State {
@@ -495,6 +522,7 @@ impl fmt::Display for State {
            Self::Archived => write!(f, "archived"),
            Self::Draft => write!(f, "draft"),
            Self::Open => write!(f, "open"),
+
            Self::Merged => write!(f, "merged"),
        }
    }
}
@@ -830,7 +858,7 @@ impl<'a, 'g> PatchMut<'a, 'g> {
        operations(&mut tx)?;
        let (ops, clock, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;

-
        self.patch.apply(ops)?;
+
        self.patch.apply(ops, self.store.as_ref())?;
        self.clock = clock;

        Ok(commit)
@@ -953,6 +981,7 @@ pub struct PatchCounts {
    pub open: usize,
    pub draft: usize,
    pub archived: usize,
+
    pub merged: usize,
}

pub struct Patches<'a> {
@@ -1010,6 +1039,7 @@ impl<'a> Patches<'a> {
                        State::Draft => state.draft += 1,
                        State::Open => state.open += 1,
                        State::Archived => state.archived += 1,
+
                        State::Merged => state.merged += 1,
                    }
                    state
                });
@@ -1059,7 +1089,7 @@ impl<'a> Patches<'a> {
    /// Get proposed patches.
    pub fn proposed(
        &self,
-
    ) -> Result<impl Iterator<Item = (PatchId, Patch, clock::Lamport)>, Error> {
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch, clock::Lamport)> + 'a, Error> {
        let all = self.all()?;

        Ok(all
@@ -1094,6 +1124,8 @@ mod test {
    use crate::cob::test::Actor;
    use crate::crypto::test::signer::MockSigner;
    use crate::test;
+
    use crate::test::arbitrary::gen;
+
    use crate::test::storage::MockRepository;

    #[derive(Clone)]
    struct Changes<const N: usize> {
@@ -1212,22 +1244,22 @@ mod test {

    #[test]
    fn prop_invariants() {
-
        fn property(log: Changes<3>) -> TestResult {
+
        fn property(repo: MockRepository, log: Changes<3>) -> TestResult {
            let t = Patch::default();
            let [p1, p2, p3] = log.permutations;

            let mut t1 = t.clone();
-
            if t1.apply(p1).is_err() {
+
            if t1.apply(p1, &repo).is_err() {
                return TestResult::discard();
            }

            let mut t2 = t.clone();
-
            if t2.apply(p2).is_err() {
+
            if t2.apply(p2, &repo).is_err() {
                return TestResult::discard();
            }

            let mut t3 = t;
-
            if t3.apply(p3).is_err() {
+
            if t3.apply(p3, &repo).is_err() {
                return TestResult::discard();
            }

@@ -1241,7 +1273,7 @@ mod test {
        qcheck::QuickCheck::new()
            .min_tests_passed(100)
            .gen(qcheck::Gen::new(7))
-
            .quickcheck(property as fn(Changes<3>) -> TestResult);
+
            .quickcheck(property as fn(MockRepository, Changes<3>) -> TestResult);
    }

    #[test]
@@ -1413,6 +1445,7 @@ mod test {
        let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
        let mut alice = Actor::<_, Action>::new(MockSigner::default());
        let mut patch = Patch::default();
+
        let repo = gen::<MockRepository>(1);

        let a1 = alice.op(Action::Revision {
            description: String::new(),
@@ -1431,20 +1464,21 @@ mod test {
            commit: oid,
        });

-
        patch.apply([a1]).unwrap();
+
        patch.apply([a1], &repo).unwrap();
        assert!(patch.revisions().next().is_some());

-
        patch.apply([a2]).unwrap();
+
        patch.apply([a2], &repo).unwrap();
        assert!(patch.revisions().next().is_none());

-
        patch.apply([a3]).unwrap_err();
-
        patch.apply([a4]).unwrap_err();
+
        patch.apply([a3], &repo).unwrap_err();
+
        patch.apply([a4], &repo).unwrap_err();
    }

    #[test]
    fn test_revision_redact_reinsert() {
        let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
        let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
+
        let repo = gen::<MockRepository>(1);
        let mut alice = Actor::<_, Action>::new(MockSigner::default());
        let mut p1 = Patch::default();
        let mut p2 = Patch::default();
@@ -1456,8 +1490,9 @@ mod test {
        });
        let a2 = alice.op(Action::Redact { revision: a1.id() });

-
        p1.apply([a1.clone(), a2.clone(), a1.clone()]).unwrap();
-
        p2.apply([a1.clone(), a1, a2]).unwrap();
+
        p1.apply([a1.clone(), a2.clone(), a1.clone()], &repo)
+
            .unwrap();
+
        p2.apply([a1.clone(), a1, a2], &repo).unwrap();

        assert_eq!(p1, p2);
    }
@@ -1466,6 +1501,7 @@ mod test {
    fn test_revision_merge_reinsert() {
        let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
        let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
+
        let repo = gen::<MockRepository>(1);
        let mut alice = Actor::<_, Action>::new(MockSigner::default());
        let mut p1 = Patch::default();
        let mut p2 = Patch::default();
@@ -1480,8 +1516,9 @@ mod test {
            commit: oid,
        });

-
        p1.apply([a1.clone(), a2.clone(), a1.clone()]).unwrap();
-
        p2.apply([a1.clone(), a1, a2]).unwrap();
+
        p1.apply([a1.clone(), a2.clone(), a1.clone()], &repo)
+
            .unwrap();
+
        p2.apply([a1.clone(), a1, a2], &repo).unwrap();

        assert_eq!(p1, p2);
    }
modified radicle/src/cob/store.rs
@@ -30,15 +30,21 @@ pub trait FromHistory: Sized + Default {
    fn type_name() -> &'static TypeName;

    /// Apply a list of operations to the state.
-
    fn apply(&mut self, ops: impl IntoIterator<Item = Op<Self::Action>>)
-
        -> Result<(), Self::Error>;
+
    fn apply<R: ReadRepository>(
+
        &mut self,
+
        ops: impl IntoIterator<Item = Op<Self::Action>>,
+
        repo: &R,
+
    ) -> Result<(), Self::Error>;

    /// Create an object from a history.
-
    fn from_history(history: &History) -> Result<(Self, Lamport), Error> {
+
    fn from_history<R: ReadRepository>(
+
        history: &History,
+
        repo: &R,
+
    ) -> Result<(Self, Lamport), Error> {
        let obj = history.traverse(Self::default(), |mut acc, entry| {
            match Ops::try_from(entry) {
                Ok(Ops(ops)) => {
-
                    if let Err(err) = acc.apply(ops) {
+
                    if let Err(err) = acc.apply(ops, repo) {
                        log::warn!("Error applying op to `{}` state: {err}", Self::type_name());
                        return ControlFlow::Break(acc);
                    }
@@ -59,9 +65,12 @@ pub trait FromHistory: Sized + Default {

    /// Create an object from individual operations.
    /// Returns an error if any of the operations fails to apply.
-
    fn from_ops(ops: impl IntoIterator<Item = Op<Self::Action>>) -> Result<Self, Self::Error> {
+
    fn from_ops<R: ReadRepository>(
+
        ops: impl IntoIterator<Item = Op<Self::Action>>,
+
        repo: &R,
+
    ) -> Result<Self, Self::Error> {
        let mut state = Self::default();
-
        state.apply(ops)?;
+
        state.apply(ops, repo)?;

        Ok(state)
    }
@@ -168,7 +177,7 @@ where
                contents,
            },
        )?;
-
        let (object, clock) = T::from_history(cob.history())?;
+
        let (object, clock) = T::from_history(cob.history(), self.repo)?;

        self.repo.sign_refs(signer).map_err(Error::SignRefs)?;

@@ -183,7 +192,7 @@ where
            if cob.manifest().history_type != HISTORY_TYPE {
                return Err(Error::HistoryType(cob.manifest().history_type.clone()));
            }
-
            let (obj, clock) = T::from_history(cob.history())?;
+
            let (obj, clock) = T::from_history(cob.history(), self.repo)?;

            Ok(Some((obj, clock)))
        } else {
@@ -194,11 +203,11 @@ where
    /// Return all objects.
    pub fn all(
        &self,
-
    ) -> Result<impl Iterator<Item = Result<(ObjectId, T, Lamport), Error>>, Error> {
+
    ) -> Result<impl Iterator<Item = Result<(ObjectId, T, Lamport), Error>> + 'a, Error> {
        let raw = cob::list(self.repo, T::type_name())?;

        Ok(raw.into_iter().map(|o| {
-
            let (obj, clock) = T::from_history(o.history())?;
+
            let (obj, clock) = T::from_history(o.history(), self.repo)?;
            Ok((*o.id(), obj, clock))
        }))
    }
@@ -295,6 +304,7 @@ impl<T: FromHistory> Transaction<T> {
        let author = self.actor;
        let timestamp = object.history().timestamp().into();
        let clock = self.clock.tick();
+
        let identity = store.parent;

        // The history clock should be in sync with the tx clock.
        assert_eq!(object.history().clock(), self.clock.get());
@@ -308,6 +318,7 @@ impl<T: FromHistory> Transaction<T> {
                author,
                clock,
                timestamp,
+
                identity,
            })
            .collect();

modified radicle/src/cob/test.rs
@@ -151,12 +151,14 @@ impl<G: Signer, A: Clone + Serialize> Actor<G, A> {
        let author = *self.signer.public_key();
        let clock = self.clock.tick();
        let timestamp = clock::Physical::now();
+
        let identity = arbitrary::oid();
        let op = Op {
            id,
            action,
            author,
            clock,
            timestamp,
+
            identity,
        };
        self.ops.insert((self.clock, author), op.clone());

modified radicle/src/cob/thread.rs
@@ -9,6 +9,7 @@ use thiserror::Error;
use crate::cob;
use crate::cob::common::{Reaction, Timestamp};
use crate::cob::{ActorId, EntryId, Op};
+
use crate::prelude::ReadRepository;

use crdt::clock::Lamport;
use crdt::{GMap, GSet, LWWSet, Max, Redactable, Semilattice};
@@ -261,7 +262,11 @@ impl cob::store::FromHistory for Thread {
        &*TYPENAME
    }

-
    fn apply(&mut self, ops: impl IntoIterator<Item = Op<Action>>) -> Result<(), OpError> {
+
    fn apply<R: ReadRepository>(
+
        &mut self,
+
        ops: impl IntoIterator<Item = Op<Action>>,
+
        _repo: &R,
+
    ) -> Result<(), OpError> {
        for op in ops.into_iter() {
            let id = op.id;
            let author = op.author;
@@ -331,6 +336,8 @@ mod tests {
    use crate::cob::test;
    use crate::crypto::test::signer::MockSigner;
    use crate::crypto::Signer;
+
    use crate::test::arbitrary::gen;
+
    use crate::test::storage::MockRepository;

    /// An object that can be used to create and sign changes.
    pub struct Actor<G> {
@@ -489,6 +496,7 @@ mod tests {
    fn test_redact_comment() {
        let tmp = tempfile::tempdir().unwrap();
        let (_, signer, _) = radicle::test::setup::context(&tmp);
+
        let repo = gen::<MockRepository>(1);
        let mut alice = Actor::new(signer);
        let mut thread = Thread::default();

@@ -496,12 +504,12 @@ mod tests {
        let a1 = alice.comment("Second comment", Some(a0.id()));
        let a2 = alice.comment("Third comment", Some(a0.id()));

-
        thread.apply([a0, a1.clone(), a2]).unwrap();
+
        thread.apply([a0, a1.clone(), a2], &repo).unwrap();
        assert_eq!(thread.comments().count(), 3);

        // Redact the second comment.
        let a3 = alice.redact(a1.id());
-
        thread.apply([a3]).unwrap();
+
        thread.apply([a3], &repo).unwrap();

        let (_, comment0) = thread.comments().nth(0).unwrap();
        let (_, comment1) = thread.comments().nth(1).unwrap();
@@ -514,13 +522,15 @@ mod tests {
    #[test]
    fn test_edit_comment() {
        let mut alice = Actor::<MockSigner>::default();
+
        let repo = gen::<MockRepository>(1);

        let c0 = alice.comment("Hello world!", None);
        let c1 = alice.edit(c0.id(), "Goodbye world.");
        let c2 = alice.edit(c0.id(), "Goodbye world!");

        let mut t1 = Thread::default();
-
        t1.apply([c0.clone(), c1.clone(), c2.clone()]).unwrap();
+
        t1.apply([c0.clone(), c1.clone(), c2.clone()], &repo)
+
            .unwrap();

        let comment = t1.comment(&c0.id());
        let edits = comment.unwrap().edits().collect::<Vec<_>>();
@@ -531,7 +541,7 @@ mod tests {
        assert_eq!(t1.comment(&c0.id()).unwrap().body(), "Goodbye world!");

        let mut t2 = Thread::default();
-
        t2.apply([c0, c2, c1]).unwrap(); // Apply in different order.
+
        t2.apply([c0, c2, c1], &repo).unwrap(); // Apply in different order.

        assert_eq!(t1, t2);
    }
@@ -610,6 +620,8 @@ mod tests {

    #[test]
    fn test_histories() {
+
        let repo = gen::<MockRepository>(1);
+

        let mut alice = Actor::<MockSigner>::default();
        let mut bob = Actor::<MockSigner>::default();
        let mut eve = Actor::<MockSigner>::default();
@@ -628,15 +640,17 @@ mod tests {
        a.merge(b);
        a.merge(e);

-
        let (expected, _) = Thread::from_history(&a).unwrap();
+
        let (expected, _) = Thread::from_history(&a, &repo).unwrap();
        for permutation in a.permutations(2) {
-
            let actual = Thread::from_ops(permutation).unwrap();
+
            let actual = Thread::from_ops(permutation, &repo).unwrap();
            assert_eq!(actual, expected);
        }
    }

    #[test]
    fn test_duplicate_comments() {
+
        let repo = gen::<MockRepository>(1);
+

        let mut alice = Actor::<MockSigner>::default();
        let mut bob = Actor::<MockSigner>::default();

@@ -649,7 +663,7 @@ mod tests {
        b.append(&b0);
        a.merge(b);

-
        let (thread, _) = Thread::from_history(&a).unwrap();
+
        let (thread, _) = Thread::from_history(&a, &repo).unwrap();

        assert_eq!(thread.comments().count(), 2);

@@ -662,6 +676,8 @@ mod tests {

    #[test]
    fn test_duplicate_comments_same_author() {
+
        let repo = gen::<MockRepository>(1);
+

        let mut alice = Actor::<MockSigner>::default();

        let a0 = alice.comment("Hello World!", None);
@@ -681,7 +697,7 @@ mod tests {
        h3.merge(h1);
        h3.merge(h2);

-
        let (thread, _) = Thread::from_history(&h3).unwrap();
+
        let (thread, _) = Thread::from_history(&h3, &repo).unwrap();

        // The three comments, distinct yet identical in terms of content, are preserved.
        assert_eq!(thread.comments().count(), 3);
@@ -700,6 +716,8 @@ mod tests {

    #[test]
    fn test_comment_edit_reinsert() {
+
        let repo = gen::<MockRepository>(1);
+

        let mut alice = Actor::<MockSigner>::default();
        let mut t1 = Thread::default();
        let mut t2 = Thread::default();
@@ -707,30 +725,31 @@ mod tests {
        let a1 = alice.comment("Hello.", None);
        let a2 = alice.edit(a1.id(), "Hello World.");

-
        t1.apply([a1.clone(), a2.clone(), a1.clone()]).unwrap();
-
        t2.apply([a1.clone(), a1, a2]).unwrap();
+
        t1.apply([a1.clone(), a2.clone(), a1.clone()], &repo)
+
            .unwrap();
+
        t2.apply([a1.clone(), a1, a2], &repo).unwrap();

        assert_eq!(t1, t2);
    }

    #[test]
    fn prop_invariants() {
-
        fn property(log: Changes<3>) -> TestResult {
+
        fn property(repo: MockRepository, log: Changes<3>) -> TestResult {
            let t = Thread::default();
            let [p1, p2, p3] = log.permutations;

            let mut t1 = t.clone();
-
            if t1.apply(p1).is_err() {
+
            if t1.apply(p1, &repo).is_err() {
                return TestResult::discard();
            }

            let mut t2 = t.clone();
-
            if t2.apply(p2).is_err() {
+
            if t2.apply(p2, &repo).is_err() {
                return TestResult::discard();
            }

            let mut t3 = t;
-
            if t3.apply(p3).is_err() {
+
            if t3.apply(p3, &repo).is_err() {
                return TestResult::discard();
            }

@@ -744,6 +763,6 @@ mod tests {
            .min_tests_passed(100)
            .max_tests(10000)
            .gen(qcheck::Gen::new(7))
-
            .quickcheck(property as fn(Changes<3>) -> TestResult);
+
            .quickcheck(property as fn(MockRepository, Changes<3>) -> TestResult);
    }
}
modified radicle/src/identity/doc.rs
@@ -127,6 +127,14 @@ pub struct DocAt {
    pub sigs: HashMap<PublicKey, Signature>,
}

+
impl Deref for DocAt {
+
    type Target = Doc<Verified>;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.doc
+
    }
+
}
+

/// An identity document.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
modified radicle/src/storage.rs
@@ -369,7 +369,15 @@ pub trait ReadRepository {
    }

    /// Get the repository's identity document.
-
    fn identity_doc(&self) -> Result<(Oid, identity::Doc<Unverified>), IdentityError>;
+
    fn identity_doc(&self) -> Result<(Oid, identity::Doc<Unverified>), IdentityError> {
+
        let head = self.identity_head()?;
+
        let doc = self.identity_doc_at(head)?;
+

+
        Ok((head, doc))
+
    }
+

+
    /// Get the repository's identity document at a specific commit.
+
    fn identity_doc_at(&self, head: Oid) -> Result<identity::Doc<Unverified>, DocError>;
}

/// Allows read-write access to a repository.
modified radicle/src/storage/git.rs
@@ -11,6 +11,7 @@ use once_cell::sync::Lazy;

use crate::git;
use crate::identity;
+
use crate::identity::doc::DocError;
use crate::identity::{Doc, Id};
use crate::identity::{Identity, IdentityError, Project};
use crate::storage::refs;
@@ -496,12 +497,8 @@ impl ReadRepository for Repository {
        Ok(Remotes::from_iter(remotes))
    }

-
    fn identity_doc(&self) -> Result<(Oid, identity::Doc<Unverified>), IdentityError> {
-
        let head = self.identity_head()?;
-

-
        Doc::<Unverified>::load_at(head, self)
-
            .map(|(doc, _)| (head, doc))
-
            .map_err(IdentityError::from)
+
    fn identity_doc_at(&self, head: Oid) -> Result<identity::Doc<Unverified>, DocError> {
+
        Doc::<Unverified>::load_at(head, self).map(|(doc, _)| doc)
    }

    fn head(&self) -> Result<(Qualified, Oid), IdentityError> {
modified radicle/src/test/arbitrary.rs
@@ -18,7 +18,7 @@ use crate::identity::{
use crate::node::Address;
use crate::storage;
use crate::storage::refs::{Refs, SignedRefs};
-
use crate::test::storage::MockStorage;
+
use crate::test::storage::{MockRepository, MockStorage};

pub fn oid() -> storage::Oid {
    let oid_bytes: [u8; 20] = gen(1);
@@ -180,6 +180,15 @@ impl Arbitrary for MockStorage {
    }
}

+
impl Arbitrary for MockRepository {
+
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
+
        let rid = Id::arbitrary(g);
+
        let doc = Doc::<Verified>::arbitrary(g);
+

+
        Self::new(rid, doc)
+
    }
+
}
+

impl Arbitrary for storage::Remote<crypto::Verified> {
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
        let refs = Refs::arbitrary(g);
modified radicle/src/test/storage.rs
@@ -6,7 +6,7 @@ use git_ref_format as fmt;
use radicle_git_ext as git_ext;

use crate::crypto::{Signer, Verified};
-
use crate::identity::doc::{Doc, Id};
+
use crate::identity::doc::{Doc, DocError, Id};
use crate::identity::IdentityError;
use crate::node::NodeId;

@@ -113,6 +113,16 @@ pub struct MockRepository {
    remotes: HashMap<NodeId, refs::SignedRefs<Verified>>,
}

+
impl MockRepository {
+
    pub fn new(id: Id, doc: Doc<Verified>) -> Self {
+
        Self {
+
            id,
+
            doc,
+
            remotes: HashMap::default(),
+
        }
+
    }
+
}
+

impl ReadRepository for MockRepository {
    fn id(&self) -> Id {
        self.id
@@ -195,6 +205,13 @@ impl ReadRepository for MockRepository {
        Ok((git2::Oid::zero().into(), self.doc.clone().unverified()))
    }

+
    fn identity_doc_at(
+
        &self,
+
        _head: Oid,
+
    ) -> Result<crate::identity::Doc<crate::crypto::Unverified>, DocError> {
+
        Ok(self.doc.clone().unverified())
+
    }
+

    fn identity_head(&self) -> Result<Oid, IdentityError> {
        todo!()
    }