Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Implement `rad-patch`
Alexis Sellier committed 3 years ago
commit 47d20003c0dee4a483d5b8c203d90adb0b29055c
parent dea268c389e891bc7f048362c766119f4a3b1928
16 files changed +853 -14
modified Cargo.lock
@@ -1579,6 +1579,7 @@ dependencies = [
 "radicle-crypto",
 "serde_json",
 "thiserror",
+
 "timeago",
 "zeroize",
]

@@ -2243,6 +2244,12 @@ dependencies = [
]

[[package]]
+
name = "timeago"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6ec32dde57efb15c035ac074118d7f32820451395f28cb0524a01d4e94983b26"
+

+
[[package]]
name = "tinyvec"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified radicle-cli/Cargo.toml
@@ -16,6 +16,7 @@ lexopt = { version = "0.2" }
log = { version = "0.4", features = ["std"] }
serde_json = { version = "1" }
thiserror = { version = "1" }
+
timeago = { version = "0.3", default-features = false }
zeroize = { version = "1.1" }

[dependencies.radicle]
modified radicle-cli/src/commands.rs
@@ -14,6 +14,8 @@ pub mod rad_init;
pub mod rad_inspect;
#[path = "commands/ls.rs"]
pub mod rad_ls;
+
#[path = "commands/patch.rs"]
+
pub mod rad_patch;
#[path = "commands/push.rs"]
pub mod rad_push;
#[path = "commands/rm.rs"]
modified radicle-cli/src/commands/ls.rs
@@ -51,7 +51,7 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        let Ok(repo) = storage.repository(id) else { return };
        let Ok((_, head)) = repo.head() else { return };
        let Ok(Doc { payload, .. }) = repo.project_of(profile.id()) else { return };
-
        let head = term::format::oid(&head);
+
        let head = term::format::oid(head);
        table.push([
            term::format::bold(payload.name),
            term::format::tertiary(id),
added radicle-cli/src/commands/patch.rs
@@ -0,0 +1,158 @@
+
#[path = "patch/common.rs"]
+
mod common;
+
#[path = "patch/create.rs"]
+
mod create;
+
#[path = "patch/list.rs"]
+
mod list;
+

+
use std::ffi::OsString;
+
use std::str::FromStr;
+

+
use anyhow::anyhow;
+

+
use radicle::cob::patch::PatchId;
+
use radicle::prelude::*;
+

+
use crate::terminal as term;
+
use crate::terminal::args::{Args, Error, Help};
+
use crate::terminal::patch::Comment;
+

+
pub const HELP: Help = Help {
+
    name: "patch",
+
    description: "Work with radicle patches",
+
    version: env!("CARGO_PKG_VERSION"),
+
    usage: r#"
+
Usage
+

+
    rad patch [<option>...]
+

+
Create options
+

+
    -u, --update [<id>]        Update an existing patch (default: no)
+
        --[no-]sync            Sync patch to seed (default: sync)
+
        --[no-]push            Push patch head to storage (default: true)
+
    -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
+

+
    -l, --list                 List all patches (default: false)
+
        --help                 Print help
+
"#,
+
};
+

+
#[derive(Debug)]
+
pub enum Update {
+
    No,
+
    Any,
+
    Patch(PatchId),
+
}
+

+
impl Default for Update {
+
    fn default() -> Self {
+
        Self::No
+
    }
+
}
+

+
#[derive(Default, Debug)]
+
pub struct Options {
+
    pub list: bool,
+
    pub verbose: bool,
+
    pub sync: bool,
+
    pub push: bool,
+
    pub update: Update,
+
    pub message: Comment,
+
}
+

+
impl Args for Options {
+
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
+
        use lexopt::prelude::*;
+

+
        let mut parser = lexopt::Parser::from_args(args);
+
        let mut list = false;
+
        let mut verbose = false;
+
        let mut sync = true;
+
        let mut message = Comment::default();
+
        let mut push = true;
+
        let mut update = Update::default();
+

+
        while let Some(arg) = parser.next()? {
+
            match arg {
+
                // Operations.
+
                Long("list") | Short('l') => {
+
                    list = true;
+
                }
+
                Long("update") | Short('u') => {
+
                    if let Ok(val) = parser.value() {
+
                        let val = val
+
                            .to_str()
+
                            .ok_or_else(|| anyhow!("patch id specified is not UTF-8"))?;
+
                        let id = PatchId::from_str(val)
+
                            .map_err(|_| anyhow!("invalid patch id '{}'", val))?;
+

+
                        update = Update::Patch(id);
+
                    } else {
+
                        update = Update::Any;
+
                    }
+
                }
+

+
                // Options.
+
                Long("message") | Short('m') => {
+
                    let txt: String = parser.value()?.to_string_lossy().into();
+
                    message.append(&txt);
+
                }
+
                Long("no-message") => {
+
                    message = Comment::Blank;
+
                }
+
                Long("sync") => {
+
                    sync = true;
+
                }
+
                Long("no-sync") => {
+
                    sync = false;
+
                }
+
                Long("push") => {
+
                    push = true;
+
                }
+
                Long("no-push") => {
+
                    push = false;
+
                }
+

+
                // Common.
+
                Long("verbose") | Short('v') => {
+
                    verbose = true;
+
                }
+
                Long("help") => {
+
                    return Err(Error::Help.into());
+
                }
+
                _ => return Err(anyhow::anyhow!(arg.unexpected())),
+
            }
+
        }
+

+
        Ok((
+
            Options {
+
                list,
+
                sync,
+
                message,
+
                push,
+
                update,
+
                verbose,
+
            },
+
            vec![],
+
        ))
+
    }
+
}
+

+
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
    let (workdir, id) = radicle::rad::cwd()
+
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
+

+
    let profile = ctx.profile()?;
+
    let storage = profile.storage.repository(id)?;
+

+
    if options.list {
+
        list::run(&storage, &profile, Some(workdir), options)?;
+
    } else {
+
        create::run(&storage, &profile, &workdir, options)?;
+
    }
+
    Ok(())
+
}
added radicle-cli/src/commands/patch/common.rs
@@ -0,0 +1,162 @@
+
use radicle::cob::patch::{MergeTarget, Patch, PatchId, PatchStore};
+
use radicle::git;
+
use radicle::git::raw::Oid;
+
use radicle::prelude::*;
+
use radicle::storage::git::Repository;
+
use radicle::storage::Remote;
+

+
use crate::terminal as term;
+
use crate::terminal::args::Error;
+

+
/// List of merge targets.
+
#[derive(Debug, Default)]
+
pub struct MergeTargets {
+
    /// Merge targets that have already merged the patch.
+
    pub merged: Vec<Remote>,
+
    /// Merge targets that haven't merged the patch.
+
    pub not_merged: Vec<(Remote, git::Oid)>,
+
}
+

+
/// Find potential merge targets for the given head.
+
pub fn find_merge_targets(
+
    head: &Oid,
+
    branch: &git::RefStr,
+
    storage: &Repository,
+
) -> anyhow::Result<MergeTargets> {
+
    let mut targets = MergeTargets::default();
+

+
    for remote in storage.remotes()? {
+
        let (_, remote) = remote?;
+
        let Some(target_oid) = remote.refs.head(branch) else {
+
            continue;
+
        };
+

+
        if is_merged(storage.raw(), target_oid.into(), *head)? {
+
            targets.merged.push(remote);
+
        } else {
+
            targets.not_merged.push((remote, target_oid));
+
        }
+
    }
+
    Ok(targets)
+
}
+

+
/// Return the [`Oid`] of the merge target.
+
pub fn patch_merge_target_oid(target: MergeTarget, repository: &Repository) -> anyhow::Result<Oid> {
+
    match target {
+
        MergeTarget::Delegates => {
+
            if let Ok((_, target)) = repository.head() {
+
                Ok(*target)
+
            } else {
+
                anyhow::bail!(
+
                    "failed to determine default branch head for project {}",
+
                    repository.id,
+
                );
+
            }
+
        }
+
    }
+
}
+

+
/// Create a human friendly message about git's sync status.
+
pub fn pretty_sync_status(
+
    repo: &git::raw::Repository,
+
    revision_oid: Oid,
+
    head_oid: Oid,
+
) -> anyhow::Result<String> {
+
    let (a, b) = repo.graph_ahead_behind(revision_oid, head_oid)?;
+
    if a == 0 && b == 0 {
+
        return Ok(term::format::dim("up to date"));
+
    }
+

+
    let ahead = term::format::positive(a);
+
    let behind = term::format::negative(b);
+

+
    Ok(format!("ahead {}, behind {}", ahead, behind))
+
}
+

+
/// Make a human friendly string for commit version information.
+
///
+
/// For example '<oid> (branch1[, branch2])'.
+
pub fn pretty_commit_version(
+
    revision_oid: &Oid,
+
    repo: &Option<git::raw::Repository>,
+
) -> anyhow::Result<String> {
+
    let mut oid = term::format::secondary(term::format::oid(*revision_oid));
+
    let mut branches: Vec<String> = vec![];
+

+
    if let Some(repo) = repo {
+
        for r in repo.references()?.flatten() {
+
            if !r.is_branch() {
+
                continue;
+
            }
+
            if let (Some(oid), Some(name)) = (&r.target(), &r.shorthand()) {
+
                if oid == revision_oid {
+
                    branches.push(name.to_string());
+
                };
+
            };
+
        }
+
    };
+
    if !branches.is_empty() {
+
        oid = format!(
+
            "{} {}",
+
            oid,
+
            term::format::yellow(format!("({})", branches.join(", "))),
+
        );
+
    }
+

+
    Ok(oid)
+
}
+

+
/// Find patches with a merge base equal to the one provided.
+
pub fn find_unmerged_with_base(
+
    patch_head: Oid,
+
    target_head: Oid,
+
    merge_base: Oid,
+
    patches: &PatchStore,
+
    workdir: &git::raw::Repository,
+
) -> anyhow::Result<Vec<(PatchId, Patch)>> {
+
    // My patches.
+
    let proposed: Vec<_> = patches.proposed_by(patches.public_key())?.collect();
+
    let mut matches = Vec::new();
+

+
    for (id, patch) in proposed {
+
        let (_, rev) = patch.latest();
+

+
        if !rev.merges.is_empty() {
+
            continue;
+
        }
+
        if **patch.head() == patch_head {
+
            continue;
+
        }
+
        // Merge-base between the two patches.
+
        if workdir.merge_base(**patch.head(), target_head)? == merge_base {
+
            matches.push((id, patch));
+
        }
+
    }
+
    Ok(matches)
+
}
+

+
/// Return commits between the merge base and a head.
+
pub fn patch_commits<'a>(
+
    repo: &'a git::raw::Repository,
+
    base: &Oid,
+
    head: &Oid,
+
) -> anyhow::Result<Vec<git::raw::Commit<'a>>> {
+
    let mut commits = Vec::new();
+
    let mut revwalk = repo.revwalk()?;
+
    revwalk.push_range(&format!("{}..{}", base, head))?;
+

+
    for rev in revwalk {
+
        let commit = repo.find_commit(rev?)?;
+
        commits.push(commit);
+
    }
+
    Ok(commits)
+
}
+

+
/// Check whether a commit has been merged into a target branch.
+
pub fn is_merged(repo: &git::raw::Repository, target: Oid, commit: Oid) -> Result<bool, Error> {
+
    if let Ok(base) = repo.merge_base(target, commit) {
+
        Ok(base == commit)
+
    } else {
+
        Ok(false)
+
    }
+
}
added radicle-cli/src/commands/patch/create.rs
@@ -0,0 +1,307 @@
+
use std::path::Path;
+

+
use anyhow::{anyhow, Context};
+

+
use radicle::cob::patch::{MergeTarget, Patch, PatchId, PatchStore};
+
use radicle::git;
+
use radicle::git::raw::Oid;
+
use radicle::prelude::*;
+
use radicle::storage::git::Repository;
+

+
use crate::terminal as term;
+
use crate::terminal::args::Error;
+

+
use super::common;
+
use super::{Options, Update};
+

+
const PATCH_MSG: &str = r#"
+
<!--
+
Please enter a patch message for your changes. An empty
+
message aborts the patch proposal.
+

+
The first line is the patch title. The patch description
+
follows, and must be separated with a blank line, just
+
like a commit message. Markdown is supported in the title
+
and description.
+
-->
+
"#;
+

+
const REVISION_MSG: &str = r#"
+
<!--
+
Please enter a comment message for your patch update. Leaving this
+
blank is also okay.
+
-->
+
"#;
+

+
/// Run patch creation.
+
pub fn run(
+
    storage: &Repository,
+
    profile: &Profile,
+
    workdir: &git::raw::Repository,
+
    options: Options,
+
) -> anyhow::Result<()> {
+
    let project = storage.project_of(&profile.public_key).context(format!(
+
        "couldn't load project {} from local state",
+
        storage.id
+
    ))?;
+

+
    term::headline(&format!(
+
        "🌱 Creating patch for {}",
+
        term::format::highlight(&project.name)
+
    ));
+

+
    let signer = term::signer(profile)?;
+
    let cobs = radicle::cob::Store::open(profile.public_key, storage)?;
+
    let patches = cobs.patches();
+

+
    // `HEAD`; This is what we are proposing as a patch.
+
    let head = workdir.head()?;
+
    let head_oid = head.target().ok_or(anyhow!("invalid HEAD ref; aborting"))?;
+
    let head_commit = workdir.find_commit(head_oid)?;
+
    let head_branch = head
+
        .shorthand()
+
        .ok_or(anyhow!("cannot create patch from detached head; aborting"))?;
+

+
    // Make sure the `HEAD` commit can be found in the monorepo. Otherwise there
+
    // is no way for anyone to merge this patch.
+
    let mut spinner = term::spinner(format!(
+
        "Looking for HEAD ({}) in storage...",
+
        term::format::secondary(term::format::oid(head_oid))
+
    ));
+
    if storage.commit(head_oid.into()).is_err() {
+
        if !options.push {
+
            spinner.failed();
+
            term::blank();
+

+
            return Err(Error::WithHint {
+
                err: anyhow!("Current branch head was not found in storage"),
+
                hint: "hint: run `git push rad` and try again",
+
            }
+
            .into());
+
        }
+
        spinner.message("Pushing HEAD to storage...");
+

+
        let output = git::run::<_, _, &str, &str>(Path::new("."), ["push", "rad"], [])?;
+
        if options.verbose {
+
            spinner.finish();
+
            term::blob(output);
+
        }
+
    }
+
    spinner.finish();
+

+
    // Determine the merge target for this patch. This can ben any tracked remote's "default"
+
    // branch, as well as your own (eg. `rad/master`).
+
    let mut spinner = term::spinner("Analyzing remotes...");
+
    let targets =
+
        common::find_merge_targets(&head_oid, project.default_branch.as_refstr(), storage)?;
+

+
    // eg. `refs/namespaces/<peer>/refs/heads/master`
+
    let (target_peer, target_oid) = match targets.not_merged.as_slice() {
+
        [] => {
+
            spinner.message("All tracked peers are up to date.");
+
            return Ok(());
+
        }
+
        [target] => target,
+
        _ => {
+
            // TODO: Let user select which branch to use as a target.
+
            todo!();
+
        }
+
    };
+
    // TODO: Tell user how many peers don't have this change.
+
    spinner.finish();
+

+
    // TODO: Handle case where `rad/master` isn't up to date with the target.
+
    // In that case we should warn the user that their master branch is not up
+
    // to date, and error out, unless the user specifies manually the merge
+
    // base.
+

+
    // The merge base is basically the commit at which the histories diverge.
+
    let base_oid = workdir.merge_base((*target_oid).into(), head_oid)?;
+
    let commits = common::patch_commits(workdir, &base_oid, &head_oid)?;
+

+
    let patch = match &options.update {
+
        Update::No => None,
+
        Update::Any => {
+
            let mut spinner = term::spinner("Finding patches to update...");
+
            let mut result = common::find_unmerged_with_base(
+
                head_oid,
+
                **target_oid,
+
                base_oid,
+
                &patches,
+
                workdir,
+
            )?;
+

+
            if let Some((id, patch)) = result.pop() {
+
                if result.is_empty() {
+
                    spinner.message(format!(
+
                        "Found existing patch {} {}",
+
                        term::format::tertiary(term::format::cob(&id)),
+
                        term::format::italic(&patch.title)
+
                    ));
+
                    spinner.finish();
+
                    term::blank();
+

+
                    Some((id, patch))
+
                } else {
+
                    spinner.failed();
+
                    term::blank();
+
                    anyhow::bail!("More than one patch available to update, please specify an id with `rad patch --update <id>`");
+
                }
+
            } else {
+
                spinner.failed();
+
                term::blank();
+
                anyhow::bail!("No patches found that share a base, please create a new patch or specify the patch id manually");
+
            }
+
        }
+
        Update::Patch(id) => {
+
            if let Some(patch) = patches.get(id)? {
+
                Some((*id, patch))
+
            } else {
+
                anyhow::bail!("Patch `{}` not found", id);
+
            }
+
        }
+
    };
+

+
    if let Some((id, patch)) = patch {
+
        if term::confirm("Update?") {
+
            term::blank();
+

+
            return update(
+
                patch, id, &base_oid, &head_oid, &patches, workdir, options, &signer,
+
            );
+
        } else {
+
            anyhow::bail!("Patch update aborted by user");
+
        }
+
    }
+

+
    // TODO: List matching working copy refs for all targets.
+

+
    term::blank();
+
    term::info!(
+
        "{}/{} ({}) <- {}/{} ({})",
+
        term::format::dim(target_peer.id),
+
        term::format::highlight(&project.default_branch.to_string()),
+
        term::format::secondary(&term::format::oid(*target_oid)),
+
        term::format::dim(term::format::node(cobs.public_key())),
+
        term::format::highlight(&head_branch.to_string()),
+
        term::format::secondary(&term::format::oid(head_oid)),
+
    );
+

+
    // TODO: Test case where the target branch has been re-written passed the merge-base, since the fork was created
+
    // This can also happen *after* the patch is created.
+

+
    term::patch::print_commits_ahead_behind(workdir, head_oid, (*target_oid).into())?;
+

+
    // List commits in patch that aren't in the target branch.
+
    term::blank();
+
    term::patch::list_commits(&commits)?;
+
    term::blank();
+

+
    if !term::confirm("Continue?") {
+
        anyhow::bail!("patch proposal aborted by user");
+
    }
+

+
    let message = head_commit
+
        .message()
+
        .ok_or(anyhow!("commit summary is not valid UTF-8; aborting"))?;
+
    let message = options.message.get(&format!("{}{}", message, PATCH_MSG));
+
    let (title, description) = message.split_once("\n\n").unwrap_or((&message, ""));
+
    let (title, description) = (title.trim(), description.trim());
+
    let description = description.replace(PATCH_MSG.trim(), ""); // Delete help message.
+

+
    if title.is_empty() {
+
        anyhow::bail!("a title must be given");
+
    }
+

+
    let title_pretty = &term::format::dim(format!("╭─ {} ───────", title));
+

+
    term::blank();
+
    term::print(title_pretty);
+
    term::blank();
+

+
    if description.is_empty() {
+
        term::print(term::format::italic("No description provided."));
+
    } else {
+
        term::markdown(&description);
+
    }
+

+
    term::blank();
+
    term::print(&term::format::dim(format!(
+
        "╰{}",
+
        "─".repeat(term::text_width(title_pretty) - 1)
+
    )));
+
    term::blank();
+

+
    if !term::confirm("Create patch?") {
+
        anyhow::bail!("patch proposal aborted by user");
+
    }
+

+
    let id = patches.create(
+
        title,
+
        &description,
+
        MergeTarget::default(),
+
        base_oid,
+
        head_oid,
+
        &[],
+
        &signer,
+
    )?;
+

+
    term::blank();
+
    term::success!("Patch {} created 🌱", term::format::highlight(id));
+

+
    if options.sync {
+
        // TODO
+
    }
+

+
    Ok(())
+
}
+

+
/// Update an existing patch with a new revision.
+
fn update<G: Signer>(
+
    patch: Patch,
+
    patch_id: PatchId,
+
    base: &Oid,
+
    head: &Oid,
+
    patches: &PatchStore,
+
    workdir: &git::raw::Repository,
+
    options: Options,
+
    signer: &G,
+
) -> anyhow::Result<()> {
+
    let (current, current_revision) = patch.latest();
+

+
    if *current_revision.oid == *head {
+
        term::info!("Nothing to do, patch is already up to date.");
+
        return Ok(());
+
    }
+

+
    term::info!(
+
        "{} {} ({}) -> {} ({})",
+
        term::format::tertiary(term::format::cob(&patch_id)),
+
        term::format::dim(format!("R{}", current)),
+
        term::format::secondary(term::format::oid(current_revision.oid)),
+
        term::format::dim(format!("R{}", current + 1)),
+
        term::format::secondary(term::format::oid(*head)),
+
    );
+
    let message = options.message.get(REVISION_MSG);
+

+
    // Difference between the two revisions.
+
    term::patch::print_commits_ahead_behind(workdir, *head, *current_revision.oid)?;
+
    term::blank();
+

+
    if !term::confirm("Continue?") {
+
        anyhow::bail!("patch update aborted by user");
+
    }
+

+
    let new = patches.update(&patch_id, message, *base, *head, signer)?;
+
    assert_eq!(new, current + 1);
+

+
    term::blank();
+
    term::success!("Patch {} updated 🌱", term::format::highlight(patch_id));
+
    term::blank();
+

+
    if options.sync {
+
        // TODO
+
    }
+

+
    Ok(())
+
}
added radicle-cli/src/commands/patch/list.rs
@@ -0,0 +1,167 @@
+
use radicle::cob;
+
use radicle::cob::patch::{Patch, PatchId, Verdict};
+
use radicle::git;
+
use radicle::prelude::*;
+
use radicle::profile::Profile;
+
use radicle::storage::git::Repository;
+

+
use crate::terminal as term;
+

+
use super::common;
+
use super::Options;
+

+
/// List patches.
+
pub fn run(
+
    storage: &Repository,
+
    profile: &Profile,
+
    workdir: Option<git::raw::Repository>,
+
    options: Options,
+
) -> anyhow::Result<()> {
+
    if options.sync {
+
        // TODO: Sync
+
    }
+

+
    let me = *profile.id();
+
    let cobs = cob::Store::open(*profile.id(), storage)?;
+
    let patches = cobs.patches();
+
    let proposed = patches.proposed()?;
+

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

+
    for (id, patch) in proposed {
+
        if *patch.author.id() == me {
+
            own.push((id, patch));
+
        } else {
+
            other.push((id, patch));
+
        }
+
    }
+
    term::blank();
+
    term::print(&term::format::badge_positive("YOU PROPOSED"));
+

+
    if own.is_empty() {
+
        term::blank();
+
        term::print(&term::format::italic("Nothing to show."));
+
    } else {
+
        for (id, patch) in &mut own {
+
            term::blank();
+

+
            print(&me, id, patch, &workdir, storage)?;
+
        }
+
    }
+
    term::blank();
+
    term::print(&term::format::badge_secondary("OTHERS PROPOSED"));
+

+
    if other.is_empty() {
+
        term::blank();
+
        term::print(&term::format::italic("Nothing to show."));
+
    } else {
+
        for (id, patch) in &mut other {
+
            term::blank();
+

+
            print(cobs.public_key(), id, patch, &workdir, storage)?;
+
        }
+
    }
+
    term::blank();
+

+
    Ok(())
+
}
+

+
/// Print patch details.
+
fn print(
+
    whoami: &PublicKey,
+
    patch_id: &PatchId,
+
    patch: &Patch,
+
    workdir: &Option<git::raw::Repository>,
+
    storage: &Repository,
+
) -> anyhow::Result<()> {
+
    let target_head = common::patch_merge_target_oid(patch.target, storage)?;
+

+
    let you = patch.author.id() == whoami;
+
    let prefix = "└─ ";
+
    let mut author_info = vec![format!(
+
        "{}* opened by {}",
+
        prefix,
+
        term::format::tertiary(patch.author.id()),
+
    )];
+

+
    if you {
+
        author_info.push(term::format::secondary("(you)"));
+
    }
+
    author_info.push(term::format::dim(term::format::timestamp(&patch.timestamp)));
+

+
    let revision = patch.revisions.last();
+
    term::info!(
+
        "{} {} {} {} {}",
+
        term::format::bold(&patch.title),
+
        term::format::highlight(term::format::cob(patch_id)),
+
        term::format::dim(format!("R{}", patch.version())),
+
        common::pretty_commit_version(&revision.oid, workdir)?,
+
        common::pretty_sync_status(storage.raw(), *revision.oid, target_head)?,
+
    );
+
    term::info!("{}", author_info.join(" "));
+

+
    let mut timeline = Vec::new();
+
    for merge in &revision.merges {
+
        let peer = storage.remote(&merge.node)?;
+
        let mut badges = Vec::new();
+

+
        if peer.delegate {
+
            badges.push(term::format::secondary("(delegate)"));
+
        }
+
        if peer.id == *whoami {
+
            badges.push(term::format::secondary("(you)"));
+
        }
+

+
        timeline.push((
+
            merge.timestamp,
+
            format!(
+
                "{}{} by {} {}",
+
                " ".repeat(term::text_width(prefix)),
+
                term::format::secondary(term::format::dim("✓ merged")),
+
                term::format::tertiary(peer.id),
+
                badges.join(" "),
+
            ),
+
        ));
+
    }
+
    for review in revision.reviews.values() {
+
        let verdict = match review.verdict {
+
            Some(Verdict::Accept) => term::format::positive(term::format::dim("✓ accepted")),
+
            Some(Verdict::Reject) => term::format::negative(term::format::dim("✗ rejected")),
+
            None => term::format::negative(term::format::dim("⋄ reviewed")),
+
        };
+
        let peer = storage.remote(review.author.id())?;
+
        let mut badges = Vec::new();
+

+
        if peer.delegate {
+
            badges.push(term::format::secondary("(delegate)"));
+
        }
+
        if peer.id == *whoami {
+
            badges.push(term::format::secondary("(you)"));
+
        }
+

+
        timeline.push((
+
            review.timestamp,
+
            format!(
+
                "{}{} by {} {}",
+
                " ".repeat(term::text_width(prefix)),
+
                verdict,
+
                term::format::tertiary(review.author.id()),
+
                badges.join(" "),
+
            ),
+
        ));
+
    }
+
    timeline.sort_by_key(|(t, _)| *t);
+

+
    for (time, event) in timeline.iter().rev() {
+
        term::info!(
+
            "{} {}",
+
            event,
+
            term::format::dim(term::format::timestamp(time))
+
        );
+
    }
+

+
    Ok(())
+
}
modified radicle-cli/src/lib.rs
@@ -1,4 +1,6 @@
#![allow(clippy::collapsible_if)]
+
#![allow(clippy::or_fun_call)]
+
#![allow(clippy::too_many_arguments)]
pub mod commands;
pub mod git;
pub mod project;
modified radicle-cli/src/terminal/format.rs
@@ -1,6 +1,7 @@
-
use std::fmt;
+
use std::{fmt, time};

use dialoguer::console::style;
+
use radicle::cob::Timestamp;
use radicle::node::NodeId;
use radicle::profile::Profile;
use radicle_cob::ObjectId;
@@ -17,8 +18,8 @@ pub fn node(node: &NodeId) -> String {
}

/// Format a git Oid.
-
pub fn oid(oid: &radicle::git::Oid) -> String {
-
    format!("{:.7}", oid)
+
pub fn oid(oid: impl Into<radicle::git::Oid>) -> String {
+
    format!("{:.7}", oid.into())
}

/// Format a COB id.
@@ -26,6 +27,15 @@ pub fn cob(id: &ObjectId) -> String {
    format!("{:.11}", id.to_string())
}

+
/// Format a timestamp.
+
pub fn timestamp(time: &Timestamp) -> String {
+
    let fmt = timeago::Formatter::new();
+
    let now = Timestamp::now();
+
    let duration = time::Duration::from_secs(now.as_secs() - time.as_secs());
+

+
    fmt.convert(duration)
+
}
+

/// Identity formatter that takes a profile and displays it as
/// `<node-id> (<username>)` depending on the configuration.
pub struct Identity<'a> {
modified radicle-cli/src/terminal/patch.rs
@@ -56,7 +56,7 @@ pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
            .summary_bytes()
            .unwrap_or_else(|| commit.message_bytes());
        table.push([
-
            term::format::secondary(term::format::oid(&commit.id().into())),
+
            term::format::secondary(term::format::oid(commit.id())),
            term::format::italic(String::from_utf8_lossy(message)),
        ]);
    }
modified radicle/src/cob.rs
@@ -8,14 +8,15 @@ pub mod transaction;
pub mod value;

pub use cob::{
-
    identity, object::collaboration::error, CollaborativeObject, Create, Entry, History, ObjectId,
-
    TypeName, Update,
+
    identity, object::collaboration::error, CollaborativeObject, Contents, Create, Entry, History,
+
    ObjectId, TypeName, Update,
};
+
pub use shared::Timestamp;
+
pub use store::Store;
+

use radicle_cob as cob;
use radicle_git_ext::Oid;

-
pub use radicle_cob::*;
-

use crate::{
    identity::{project::Identity, Did},
    node::NodeId,
modified radicle/src/cob/store.rs
@@ -111,6 +111,11 @@ impl<'a, T> Store<'a, T> {
    pub fn author(&self) -> Author {
        Author::new(self.whoami)
    }
+

+
    /// Get the public key associated with this store.
+
    pub fn public_key(&self) -> &PublicKey {
+
        &self.whoami
+
    }
}

impl<'a, T: FromHistory> Store<'a, T> {
modified radicle/src/rad.rs
@@ -18,6 +18,7 @@ use crate::storage::refs::SignedRefs;
use crate::storage::{BranchName, ReadRepository as _, RemoteId, WriteRepository as _};
use crate::{identity, storage};

+
/// Name of the radicle storage remote.
pub static REMOTE_NAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("rad"));

/// Radicle remote name for peer, eg. `rad/<node-id>`
@@ -346,14 +347,15 @@ mod tests {

    use radicle_crypto::test::signer::MockSigner;

-
    use super::*;
-
    use crate::git::fmt::refname;
+
    use crate::git::{name::component, qualified, Qualified};
    use crate::identity::{Delegate, Did};
    use crate::storage::git::transport;
    use crate::storage::git::Storage;
    use crate::storage::{ReadStorage, WriteStorage};
    use crate::test::fixtures;

+
    use super::*;
+

    #[test]
    fn test_init() {
        let tempdir = tempfile::tempdir().unwrap();
@@ -387,6 +389,7 @@ mod tests {
        let (_, head) = project_repo.head().unwrap();

        // Test canonical refs.
+
        assert_eq!(refs.head(&component!("master")).unwrap(), head);
        assert_eq!(project_repo.raw().refname_to_id("HEAD").unwrap(), *head);
        assert_eq!(
            project_repo
@@ -439,8 +442,11 @@ mod tests {
        let bob_remote = storage.repository(id).unwrap().remote(bob_id).unwrap();

        assert_eq!(
-
            bob_remote.refs.get(&refname!("master")),
-
            alice_refs.get(&refname!("master"))
+
            bob_remote
+
                .refs
+
                .get(&qualified!("refs/heads/master"))
+
                .unwrap(),
+
            alice_refs.get(&qualified!("refs/heads/master")).unwrap()
        );
    }

modified radicle/src/storage.rs
@@ -185,7 +185,7 @@ impl<V> From<Remotes<V>> for HashMap<RemoteId, Refs> {

/// A project remote.
#[derive(Debug, Clone, PartialEq, Eq)]
-
pub struct Remote<V> {
+
pub struct Remote<V = Verified> {
    /// ID of remote.
    pub id: PublicKey,
    /// Git references published under this remote, and their hashes.
modified radicle/src/storage/refs.rs
@@ -101,6 +101,17 @@ impl Refs {
        })
    }

+
    /// Get a particular ref.
+
    pub fn get(&self, name: &git::Qualified) -> Option<Oid> {
+
        self.0.get(name.to_ref_string().as_refstr()).copied()
+
    }
+

+
    /// Get a particular head ref.
+
    pub fn head(&self, name: impl AsRef<git::RefStr>) -> Option<Oid> {
+
        let branch = git::refname!("refs/heads").join(name);
+
        self.0.get(&branch).copied()
+
    }
+

    /// Create refs from a canonical representation.
    pub fn from_canonical(bytes: &[u8]) -> Result<Self, canonical::Error> {
        let reader = BufReader::new(bytes);