Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: Organize patch functions better
cloudhead committed 2 years ago
commit bb06663d1c9388479c1638624e293a19fceaba22
parent 8928c5ee2628e0b4d738289b5030fc3e2a918e5e
8 files changed +570 -548
modified radicle-cli/src/commands/patch.rs
@@ -6,8 +6,6 @@ mod assign;
mod checkout;
#[path = "patch/comment.rs"]
mod comment;
-
#[path = "patch/common.rs"]
-
mod common;
#[path = "patch/delete.rs"]
mod delete;
#[path = "patch/diff.rs"]
deleted radicle-cli/src/commands/patch/common.rs
@@ -1,107 +0,0 @@
-
use anyhow::anyhow;
-

-
use radicle::git;
-
use radicle::git::raw::Oid;
-
use radicle::prelude::*;
-
use radicle::storage::git::Repository;
-

-
use crate::terminal as term;
-

-
/// Give the oid of the branch or an appropriate error.
-
#[inline]
-
pub fn branch_oid(branch: &git::raw::Branch) -> anyhow::Result<git::Oid> {
-
    let oid = branch
-
        .get()
-
        .target()
-
        .ok_or(anyhow!("invalid HEAD ref; aborting"))?;
-
    Ok(oid.into())
-
}
-

-
#[inline]
-
fn get_branch(git_ref: git::Qualified) -> git::RefString {
-
    let (_, _, head, tail) = git_ref.non_empty_components();
-
    std::iter::once(head).chain(tail).collect()
-
}
-

-
/// Determine the merge target for this patch. This can be any followed remote's "default" branch,
-
/// as well as your own (eg. `rad/master`).
-
pub fn get_merge_target(
-
    storage: &Repository,
-
    head_branch: &git::raw::Branch,
-
) -> anyhow::Result<(git::RefString, git::Oid)> {
-
    let (qualified_ref, target_oid) = storage.canonical_head()?;
-
    let head_oid = branch_oid(head_branch)?;
-
    let merge_base = storage.raw().merge_base(*head_oid, *target_oid)?;
-

-
    if head_oid == merge_base.into() {
-
        anyhow::bail!("commits are already included in the target branch; nothing to do");
-
    }
-

-
    Ok((get_branch(qualified_ref), (*target_oid).into()))
-
}
-

-
/// Get the diff stats between two commits.
-
/// Should match the default output of `git diff <old> <new> --stat` exactly.
-
pub fn diff_stats(
-
    repo: &git::raw::Repository,
-
    old: &Oid,
-
    new: &Oid,
-
) -> Result<git::raw::DiffStats, git::raw::Error> {
-
    let old = repo.find_commit(*old)?;
-
    let new = repo.find_commit(*new)?;
-
    let old_tree = old.tree()?;
-
    let new_tree = new.tree()?;
-
    let mut diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?;
-
    let mut find_opts = git::raw::DiffFindOptions::new();
-

-
    diff.find_similar(Some(&mut find_opts))?;
-
    diff.stats()
-
}
-

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

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

-
    Ok(term::Line::default()
-
        .item("ahead ")
-
        .item(ahead)
-
        .item(", behind ")
-
        .item(behind))
-
}
-

-
/// Get the branches that point to a commit.
-
pub fn branches(target: &Oid, repo: &git::raw::Repository) -> anyhow::Result<Vec<String>> {
-
    let mut branches: Vec<String> = vec![];
-

-
    for r in repo.references()?.flatten() {
-
        if !r.is_branch() {
-
            continue;
-
        }
-
        if let (Some(oid), Some(name)) = (&r.target(), &r.shorthand()) {
-
            if oid == target {
-
                branches.push(name.to_string());
-
            };
-
        };
-
    }
-
    Ok(branches)
-
}
-

-
#[inline]
-
pub fn try_branch(reference: git::raw::Reference<'_>) -> anyhow::Result<git::raw::Branch> {
-
    let branch = if reference.is_branch() {
-
        git::raw::Branch::wrap(reference)
-
    } else {
-
        anyhow::bail!("cannot create patch from detached head; aborting")
-
    };
-
    Ok(branch)
-
}
modified radicle-cli/src/commands/patch/list.rs
@@ -1,11 +1,7 @@
use std::collections::BTreeSet;
-
use std::iter;

-
use radicle::cob;
use radicle::cob::patch;
-
use radicle::cob::patch::{Patch, PatchId, Patches, Verdict};
-
use radicle::git;
-
use radicle::patch::{Merge, Review, Revision, RevisionId};
+
use radicle::cob::patch::{Patch, PatchId, Patches};
use radicle::prelude::*;
use radicle::profile::Profile;
use radicle::storage::git::Repository;
@@ -14,8 +10,8 @@ use term::format::Author;
use term::table::{Table, TableOptions};
use term::Element as _;

-
use super::common;
use crate::terminal as term;
+
use crate::terminal::patch as common;

/// List patches.
pub fn run(
@@ -128,321 +124,3 @@ pub fn row(
            .into(),
    ])
}
-

-
pub fn timeline<'a>(
-
    profile: &'a Profile,
-
    patch: &'a Patch,
-
) -> impl Iterator<Item = term::Line> + 'a {
-
    Timeline::build(profile, patch).into_lines(profile)
-
}
-

-
/// The timeline of a [`Patch`].
-
///
-
/// A `Patch` will always have opened with a root revision and may
-
/// have a series of revisions that update the patch.
-
///
-
/// The function, [`timeline`], builds a `Timeline` and converts it
-
/// into a series of [`term::Line`]s.
-
struct Timeline<'a> {
-
    opened: Opened<'a>,
-
    revisions: Vec<RevisionEntry<'a>>,
-
}
-

-
impl<'a> Timeline<'a> {
-
    fn build(profile: &Profile, patch: &'a Patch) -> Self {
-
        let opened = Opened::from_patch(patch, profile);
-
        let mut revisions = patch
-
            .revisions()
-
            .skip(1) // skip the root revision since it's handled in `Opened::from_patch`
-
            .map(|(id, revision)| {
-
                (
-
                    revision.timestamp(),
-
                    RevisionEntry::from_revision(patch, id, revision, profile),
-
                )
-
            })
-
            .collect::<Vec<_>>();
-
        revisions.sort_by_key(|(t, _)| *t);
-
        Timeline {
-
            opened,
-
            revisions: revisions.into_iter().map(|(_, e)| e).collect(),
-
        }
-
    }
-

-
    fn into_lines(self, profile: &'a Profile) -> impl Iterator<Item = term::Line> + 'a {
-
        self.opened.into_lines(profile).chain(
-
            self.revisions
-
                .into_iter()
-
                .flat_map(|r| r.into_lines(profile)),
-
        )
-
    }
-
}
-

-
/// The root `Revision` of the `Patch`.
-
struct Opened<'a> {
-
    /// The `Author` of the patch.
-
    author: Author<'a>,
-
    /// When the patch was created.
-
    timestamp: cob::Timestamp,
-
    /// The commit head of the `Revision`.
-
    head: git::Oid,
-
    /// Any updates performed on the root `Revision`.
-
    updates: Vec<Update<'a>>,
-
}
-

-
impl<'a> Opened<'a> {
-
    fn from_patch(patch: &'a Patch, profile: &Profile) -> Self {
-
        let (root, revision) = patch.root();
-
        let mut updates = Vec::new();
-
        updates.extend(revision.reviews().map(|(_, review)| {
-
            (
-
                review.timestamp(),
-
                Update::Reviewed {
-
                    review: review.clone(),
-
                },
-
            )
-
        }));
-
        updates.extend(patch.merges().filter_map(|(_, merge)| {
-
            if merge.revision == root {
-
                Some((
-
                    merge.timestamp,
-
                    Update::Merged {
-
                        author: Author::new(&revision.author().id, profile),
-
                        merge: merge.clone(),
-
                    },
-
                ))
-
            } else {
-
                None
-
            }
-
        }));
-
        updates.sort_by_key(|(t, _)| *t);
-
        Opened {
-
            author: Author::new(&patch.author().id, profile),
-
            timestamp: patch.timestamp(),
-
            head: revision.head(),
-
            updates: updates.into_iter().map(|(_, up)| up).collect(),
-
        }
-
    }
-

-
    fn into_lines(self, profile: &'a Profile) -> impl Iterator<Item = term::Line> + 'a {
-
        iter::once(
-
            term::Line::spaced([
-
                term::format::positive("●").into(),
-
                term::format::default("opened by").into(),
-
            ])
-
            .space()
-
            .extend(self.author.line())
-
            .space()
-
            .extend(term::Line::spaced([
-
                term::format::parens(term::format::secondary(term::format::oid(self.head))).into(),
-
                term::format::dim(term::format::timestamp(self.timestamp)).into(),
-
            ])),
-
        )
-
        .chain(self.updates.into_iter().map(|up| {
-
            term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
-
                .extend(up.into_line(profile))
-
        }))
-
    }
-
}
-

-
/// A revision entry in the [`Timeline`].
-
enum RevisionEntry<'a> {
-
    /// An `Updated` entry means that the original author of the
-
    /// `Patch` created a new revision.
-
    Updated {
-
        /// When the `Revision` was created.
-
        timestamp: cob::Timestamp,
-
        /// The id of the `Revision`.
-
        id: RevisionId,
-
        /// The commit head of the `Revision`.
-
        head: git::Oid,
-
        /// All [`Update`]s that occurred on the `Revision`.
-
        updates: Vec<Update<'a>>,
-
    },
-
    /// A `Revised` entry means that an author other than the original
-
    /// author of the `Patch` created a new revision.
-
    Revised {
-
        /// The `Author` that created the `Revision` (that is not the
-
        /// `Patch` author).
-
        author: Author<'a>,
-
        /// When the `Revision` was created.
-
        timestamp: cob::Timestamp,
-
        /// The id of the `Revision`.
-
        id: RevisionId,
-
        /// The commit head of the `Revision`.
-
        head: git::Oid,
-
        /// All [`Update`]s that occurred on the `Revision`.
-
        updates: Vec<Update<'a>>,
-
    },
-
}
-

-
impl<'a> RevisionEntry<'a> {
-
    fn from_revision(
-
        patch: &Patch,
-
        id: RevisionId,
-
        revision: &'a Revision,
-
        profile: &Profile,
-
    ) -> Self {
-
        let mut updates = Vec::new();
-
        updates.extend(revision.reviews().map(|(_, review)| {
-
            (
-
                review.timestamp(),
-
                Update::Reviewed {
-
                    review: review.clone(),
-
                },
-
            )
-
        }));
-
        updates.extend(patch.merges().filter_map(|(_, merge)| {
-
            if merge.revision == id {
-
                Some((
-
                    merge.timestamp,
-
                    Update::Merged {
-
                        author: Author::new(&revision.author().id, profile),
-
                        merge: merge.clone(),
-
                    },
-
                ))
-
            } else {
-
                None
-
            }
-
        }));
-
        updates.sort_by_key(|(t, _)| *t);
-

-
        if revision.author() == patch.author() {
-
            RevisionEntry::Updated {
-
                timestamp: revision.timestamp(),
-
                id,
-
                head: revision.head(),
-
                updates: updates.into_iter().map(|(_, up)| up).collect(),
-
            }
-
        } else {
-
            RevisionEntry::Revised {
-
                author: Author::new(&revision.author().id, profile),
-
                timestamp: revision.timestamp(),
-
                id,
-
                head: revision.head(),
-
                updates: updates.into_iter().map(|(_, up)| up).collect(),
-
            }
-
        }
-
    }
-

-
    fn into_lines(self, profile: &'a Profile) -> Vec<term::Line> {
-
        match self {
-
            RevisionEntry::Updated {
-
                timestamp,
-
                id,
-
                head,
-
                updates,
-
            } => Self::updated(profile, timestamp, id, head, updates).collect(),
-
            RevisionEntry::Revised {
-
                author,
-
                timestamp,
-
                id,
-
                head,
-
                updates,
-
            } => Self::revised(profile, author, timestamp, id, head, updates).collect(),
-
        }
-
    }
-

-
    fn updated(
-
        profile: &'a Profile,
-
        timestamp: cob::Timestamp,
-
        id: RevisionId,
-
        head: git::Oid,
-
        updates: Vec<Update<'a>>,
-
    ) -> impl Iterator<Item = term::Line> + 'a {
-
        iter::once(term::Line::spaced([
-
            term::format::tertiary("↑").into(),
-
            term::format::default("updated to").into(),
-
            term::format::dim(id).into(),
-
            term::format::parens(term::format::secondary(term::format::oid(head))).into(),
-
            term::format::dim(term::format::timestamp(timestamp)).into(),
-
        ]))
-
        .chain(updates.into_iter().map(|up| {
-
            term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
-
                .extend(up.into_line(profile))
-
        }))
-
    }
-

-
    fn revised(
-
        profile: &'a Profile,
-
        author: Author<'a>,
-
        timestamp: cob::Timestamp,
-
        id: RevisionId,
-
        head: git::Oid,
-
        updates: Vec<Update<'a>>,
-
    ) -> impl Iterator<Item = term::Line> + 'a {
-
        let (alias, nid) = author.labels();
-
        iter::once(term::Line::spaced([
-
            term::format::tertiary("*").into(),
-
            term::format::default("revised by").into(),
-
            alias,
-
            nid,
-
            term::format::default("in").into(),
-
            term::format::dim(term::format::oid(id)).into(),
-
            term::format::parens(term::format::secondary(term::format::oid(head))).into(),
-
            term::format::dim(term::format::timestamp(timestamp)).into(),
-
        ]))
-
        .chain(updates.into_iter().map(|up| {
-
            term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
-
                .extend(up.into_line(profile))
-
        }))
-
    }
-
}
-

-
/// An update in the [`Patch`]'s timeline.
-
enum Update<'a> {
-
    /// A revision of the patch was reviewed.
-
    Reviewed { review: Review },
-
    /// A revision of the patch was merged.
-
    Merged { author: Author<'a>, merge: Merge },
-
}
-

-
impl<'a> Update<'a> {
-
    fn timestamp(&self) -> cob::Timestamp {
-
        match self {
-
            Update::Reviewed { review } => review.timestamp(),
-
            Update::Merged { merge, .. } => merge.timestamp,
-
        }
-
    }
-

-
    fn into_line(self, profile: &Profile) -> term::Line {
-
        let timestamp = self.timestamp();
-
        let mut line = match self {
-
            Update::Reviewed { review } => {
-
                let verdict = review.verdict();
-
                let verdict_symbol = match verdict {
-
                    Some(Verdict::Accept) => term::format::positive("✓"),
-
                    Some(Verdict::Reject) => term::format::negative("✗"),
-
                    None => term::format::dim("⋄"),
-
                };
-
                let verdict_verb = match verdict {
-
                    Some(Verdict::Accept) => term::format::default("accepted"),
-
                    Some(Verdict::Reject) => term::format::default("rejected"),
-
                    None => term::format::default("reviewed"),
-
                };
-
                term::Line::spaced([
-
                    verdict_symbol.into(),
-
                    verdict_verb.into(),
-
                    term::format::default("by").into(),
-
                ])
-
                .space()
-
                .extend(Author::new(&review.author().id.into(), profile).line())
-
            }
-
            Update::Merged { author, merge } => {
-
                let (alias, nid) = author.labels();
-
                term::Line::spaced([
-
                    term::format::primary("✓").bold().into(),
-
                    term::format::default("merged by").into(),
-
                    alias,
-
                    nid,
-
                    term::format::default("at revision").into(),
-
                    term::format::dim(term::format::oid(merge.revision)).into(),
-
                    term::format::parens(term::format::secondary(term::format::oid(merge.commit)))
-
                        .into(),
-
                ])
-
            }
-
        };
-
        line.push(term::Label::space());
-
        line.push(term::format::dim(term::format::timestamp(timestamp)));
-
        line
-
    }
-
}
modified radicle-cli/src/commands/patch/show.rs
@@ -3,10 +3,6 @@ use std::process;
use radicle::cob::patch;
use radicle::git;
use radicle::storage::git::Repository;
-
use radicle_term::{
-
    table::{Table, TableOptions},
-
    textarea, Element, VStack,
-
};

use crate::terminal as term;

@@ -27,32 +23,6 @@ fn show_patch_diff(patch: &patch::Patch, stored: &Repository) -> anyhow::Result<
    Ok(())
}

-
fn patch_commits(patch: &patch::Patch, stored: &Repository) -> anyhow::Result<Vec<term::Line>> {
-
    let (from, to) = patch.range()?;
-
    let range = format!("{}..{}", from, to);
-

-
    let mut revwalk = stored.revwalk(*patch.head())?;
-
    let mut lines = Vec::new();
-

-
    revwalk.push_range(&range)?;
-

-
    for commit in revwalk {
-
        let commit = commit?;
-
        let commit = stored.raw().find_commit(commit)?;
-

-
        lines.push(term::Line::spaced([
-
            term::label(term::format::secondary::<String>(
-
                term::format::oid(commit.id()).into(),
-
            )),
-
            term::label(term::format::default(
-
                commit.summary().unwrap_or_default().to_owned(),
-
            )),
-
        ]));
-
    }
-

-
    Ok(lines)
-
}
-

pub fn run(
    patch_id: &PatchId,
    diff: bool,
@@ -72,90 +42,7 @@ pub fn run(
        println!("{:#?}", patch);
        return Ok(());
    }
-

-
    let (_, revision) = patch.latest();
-
    let state = patch.state();
-
    let branches = common::branches(&revision.head(), workdir)?;
-
    let ahead_behind = common::ahead_behind(
-
        stored.raw(),
-
        *revision.head(),
-
        *patch.target().head(stored)?,
-
    )?;
-
    let author = patch.author();
-
    let author = term::format::Author::new(author.id(), profile);
-
    let labels = patch.labels().map(|l| l.to_string()).collect::<Vec<_>>();
-

-
    let mut attrs = Table::<2, term::Line>::new(TableOptions {
-
        spacing: 2,
-
        ..TableOptions::default()
-
    });
-
    attrs.push([
-
        term::format::tertiary("Title".to_owned()).into(),
-
        term::format::bold(patch.title().to_owned()).into(),
-
    ]);
-
    attrs.push([
-
        term::format::tertiary("Patch".to_owned()).into(),
-
        term::format::default(patch_id.to_string()).into(),
-
    ]);
-
    attrs.push([
-
        term::format::tertiary("Author".to_owned()).into(),
-
        author.line(),
-
    ]);
-
    if !labels.is_empty() {
-
        attrs.push([
-
            term::format::tertiary("Labels".to_owned()).into(),
-
            term::format::secondary(labels.join(", ")).into(),
-
        ]);
-
    }
-
    attrs.push([
-
        term::format::tertiary("Head".to_owned()).into(),
-
        term::format::secondary(revision.head().to_string()).into(),
-
    ]);
-
    if verbose {
-
        attrs.push([
-
            term::format::tertiary("Base".to_owned()).into(),
-
            term::format::secondary(revision.base().to_string()).into(),
-
        ]);
-
    }
-
    if !branches.is_empty() {
-
        attrs.push([
-
            term::format::tertiary("Branches".to_owned()).into(),
-
            term::format::yellow(branches.join(", ")).into(),
-
        ]);
-
    }
-
    attrs.push([
-
        term::format::tertiary("Commits".to_owned()).into(),
-
        ahead_behind,
-
    ]);
-
    attrs.push([
-
        term::format::tertiary("Status".to_owned()).into(),
-
        match state {
-
            patch::State::Open { .. } => term::format::positive(state.to_string()),
-
            patch::State::Draft => term::format::dim(state.to_string()),
-
            patch::State::Archived => term::format::yellow(state.to_string()),
-
            patch::State::Merged { .. } => term::format::primary(state.to_string()),
-
        }
-
        .into(),
-
    ]);
-

-
    let commits = patch_commits(&patch, stored)?;
-
    let description = patch.description().trim();
-
    let mut widget = VStack::default()
-
        .border(Some(term::colors::FAINT))
-
        .child(attrs)
-
        .children(if !description.is_empty() {
-
            vec![term::Label::blank().boxed(), textarea(description).boxed()]
-
        } else {
-
            vec![]
-
        })
-
        .divider()
-
        .children(commits.into_iter().map(|l| l.boxed()))
-
        .divider();
-

-
    for line in list::timeline(profile, &patch) {
-
        widget.push(line);
-
    }
-
    widget.print();
+
    term::patch::show(&patch, patch_id, verbose, stored, Some(workdir), profile)?;

    if diff {
        term::blank();
modified radicle-cli/src/commands/patch/update.rs
@@ -3,8 +3,8 @@ use radicle::git;
use radicle::prelude::*;
use radicle::storage::git::Repository;

-
use super::common::*;
use crate::terminal as term;
+
use crate::terminal::patch::*;

/// Run patch update.
pub fn run(
modified radicle-cli/src/terminal/patch.rs
@@ -1,3 +1,6 @@
+
mod common;
+
mod timeline;
+

use std::fmt;
use std::fmt::Write;
use std::io;
@@ -8,10 +11,16 @@ use thiserror::Error;
use radicle::cob;
use radicle::cob::patch;
use radicle::git;
+
use radicle::patch::{Patch, PatchId};
+
use radicle::prelude::Profile;
+
use radicle::storage::git::Repository;
+
use radicle::storage::WriteRepository as _;

use crate::terminal as term;
use crate::terminal::Element;

+
pub use common::*;
+

#[derive(Debug, Error)]
pub enum Error {
    #[error(transparent)]
@@ -329,6 +338,128 @@ pub fn print_commits_ahead_behind(
    Ok(())
}

+
pub fn show(
+
    patch: &Patch,
+
    id: &PatchId,
+
    verbose: bool,
+
    stored: &Repository,
+
    workdir: Option<&git::raw::Repository>,
+
    profile: &Profile,
+
) -> anyhow::Result<()> {
+
    let (_, revision) = patch.latest();
+
    let state = patch.state();
+
    let branches = if let Some(wd) = workdir {
+
        common::branches(&revision.head(), wd)?
+
    } else {
+
        vec![]
+
    };
+
    let ahead_behind = common::ahead_behind(
+
        stored.raw(),
+
        *revision.head(),
+
        *patch.target().head(stored)?,
+
    )?;
+
    let author = patch.author();
+
    let author = term::format::Author::new(author.id(), profile);
+
    let labels = patch.labels().map(|l| l.to_string()).collect::<Vec<_>>();
+

+
    let mut attrs = term::Table::<2, term::Line>::new(term::TableOptions {
+
        spacing: 2,
+
        ..term::TableOptions::default()
+
    });
+
    attrs.push([
+
        term::format::tertiary("Title".to_owned()).into(),
+
        term::format::bold(patch.title().to_owned()).into(),
+
    ]);
+
    attrs.push([
+
        term::format::tertiary("Patch".to_owned()).into(),
+
        term::format::default(id.to_string()).into(),
+
    ]);
+
    attrs.push([
+
        term::format::tertiary("Author".to_owned()).into(),
+
        author.line(),
+
    ]);
+
    if !labels.is_empty() {
+
        attrs.push([
+
            term::format::tertiary("Labels".to_owned()).into(),
+
            term::format::secondary(labels.join(", ")).into(),
+
        ]);
+
    }
+
    attrs.push([
+
        term::format::tertiary("Head".to_owned()).into(),
+
        term::format::secondary(revision.head().to_string()).into(),
+
    ]);
+
    if verbose {
+
        attrs.push([
+
            term::format::tertiary("Base".to_owned()).into(),
+
            term::format::secondary(revision.base().to_string()).into(),
+
        ]);
+
    }
+
    if !branches.is_empty() {
+
        attrs.push([
+
            term::format::tertiary("Branches".to_owned()).into(),
+
            term::format::yellow(branches.join(", ")).into(),
+
        ]);
+
    }
+
    attrs.push([
+
        term::format::tertiary("Commits".to_owned()).into(),
+
        ahead_behind,
+
    ]);
+
    attrs.push([
+
        term::format::tertiary("Status".to_owned()).into(),
+
        match state {
+
            patch::State::Open { .. } => term::format::positive(state.to_string()),
+
            patch::State::Draft => term::format::dim(state.to_string()),
+
            patch::State::Archived => term::format::yellow(state.to_string()),
+
            patch::State::Merged { .. } => term::format::primary(state.to_string()),
+
        }
+
        .into(),
+
    ]);
+

+
    let commits = patch_commit_lines(patch, stored)?;
+
    let description = patch.description().trim();
+
    let mut widget = term::VStack::default()
+
        .border(Some(term::colors::FAINT))
+
        .child(attrs)
+
        .children(if !description.is_empty() {
+
            vec![
+
                term::Label::blank().boxed(),
+
                term::textarea(description).boxed(),
+
            ]
+
        } else {
+
            vec![]
+
        })
+
        .divider()
+
        .children(commits.into_iter().map(|l| l.boxed()))
+
        .divider();
+

+
    for line in timeline::timeline(profile, patch) {
+
        widget.push(line);
+
    }
+
    widget.print();
+

+
    Ok(())
+
}
+

+
fn patch_commit_lines(
+
    patch: &patch::Patch,
+
    stored: &Repository,
+
) -> anyhow::Result<Vec<term::Line>> {
+
    let (from, to) = patch.range()?;
+
    let mut lines = Vec::new();
+

+
    for commit in patch_commits(stored.raw(), &from, &to)? {
+
        lines.push(term::Line::spaced([
+
            term::label(term::format::secondary::<String>(
+
                term::format::oid(commit.id()).into(),
+
            )),
+
            term::label(term::format::default(
+
                commit.summary().unwrap_or_default().to_owned(),
+
            )),
+
        ]));
+
    }
+
    Ok(lines)
+
}
+

#[cfg(test)]
mod test {
    use super::*;
added radicle-cli/src/terminal/patch/common.rs
@@ -0,0 +1,107 @@
+
use anyhow::anyhow;
+

+
use radicle::git;
+
use radicle::git::raw::Oid;
+
use radicle::prelude::*;
+
use radicle::storage::git::Repository;
+

+
use crate::terminal as term;
+

+
/// Give the oid of the branch or an appropriate error.
+
#[inline]
+
pub fn branch_oid(branch: &git::raw::Branch) -> anyhow::Result<git::Oid> {
+
    let oid = branch
+
        .get()
+
        .target()
+
        .ok_or(anyhow!("invalid HEAD ref; aborting"))?;
+
    Ok(oid.into())
+
}
+

+
#[inline]
+
fn get_branch(git_ref: git::Qualified) -> git::RefString {
+
    let (_, _, head, tail) = git_ref.non_empty_components();
+
    std::iter::once(head).chain(tail).collect()
+
}
+

+
/// Determine the merge target for this patch. This can be any followed remote's "default" branch,
+
/// as well as your own (eg. `rad/master`).
+
pub fn get_merge_target(
+
    storage: &Repository,
+
    head_branch: &git::raw::Branch,
+
) -> anyhow::Result<(git::RefString, git::Oid)> {
+
    let (qualified_ref, target_oid) = storage.canonical_head()?;
+
    let head_oid = branch_oid(head_branch)?;
+
    let merge_base = storage.raw().merge_base(*head_oid, *target_oid)?;
+

+
    if head_oid == merge_base.into() {
+
        anyhow::bail!("commits are already included in the target branch; nothing to do");
+
    }
+

+
    Ok((get_branch(qualified_ref), (*target_oid).into()))
+
}
+

+
/// Get the diff stats between two commits.
+
/// Should match the default output of `git diff <old> <new> --stat` exactly.
+
pub fn diff_stats(
+
    repo: &git::raw::Repository,
+
    old: &Oid,
+
    new: &Oid,
+
) -> Result<git::raw::DiffStats, git::raw::Error> {
+
    let old = repo.find_commit(*old)?;
+
    let new = repo.find_commit(*new)?;
+
    let old_tree = old.tree()?;
+
    let new_tree = new.tree()?;
+
    let mut diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?;
+
    let mut find_opts = git::raw::DiffFindOptions::new();
+

+
    diff.find_similar(Some(&mut find_opts))?;
+
    diff.stats()
+
}
+

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

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

+
    Ok(term::Line::default()
+
        .item("ahead ")
+
        .item(ahead)
+
        .item(", behind ")
+
        .item(behind))
+
}
+

+
/// Get the branches that point to a commit.
+
pub fn branches(target: &Oid, repo: &git::raw::Repository) -> anyhow::Result<Vec<String>> {
+
    let mut branches: Vec<String> = vec![];
+

+
    for r in repo.references()?.flatten() {
+
        if !r.is_branch() {
+
            continue;
+
        }
+
        if let (Some(oid), Some(name)) = (&r.target(), &r.shorthand()) {
+
            if oid == target {
+
                branches.push(name.to_string());
+
            };
+
        };
+
    }
+
    Ok(branches)
+
}
+

+
#[inline]
+
pub fn try_branch(reference: git::raw::Reference<'_>) -> anyhow::Result<git::raw::Branch> {
+
    let branch = if reference.is_branch() {
+
        git::raw::Branch::wrap(reference)
+
    } else {
+
        anyhow::bail!("cannot create patch from detached head; aborting")
+
    };
+
    Ok(branch)
+
}
added radicle-cli/src/terminal/patch/timeline.rs
@@ -0,0 +1,328 @@
+
use std::iter;
+

+
use radicle::cob;
+
use radicle::cob::patch::{Patch, Verdict};
+
use radicle::git;
+
use radicle::patch::{Merge, Review, Revision, RevisionId};
+
use radicle::profile::Profile;
+

+
use crate::terminal as term;
+
use crate::terminal::format::Author;
+

+
pub fn timeline<'a>(
+
    profile: &'a Profile,
+
    patch: &'a Patch,
+
) -> impl Iterator<Item = term::Line> + 'a {
+
    Timeline::build(profile, patch).into_lines(profile)
+
}
+

+
/// The timeline of a [`Patch`].
+
///
+
/// A `Patch` will always have opened with a root revision and may
+
/// have a series of revisions that update the patch.
+
///
+
/// The function, [`timeline`], builds a `Timeline` and converts it
+
/// into a series of [`term::Line`]s.
+
struct Timeline<'a> {
+
    opened: Opened<'a>,
+
    revisions: Vec<RevisionEntry<'a>>,
+
}
+

+
impl<'a> Timeline<'a> {
+
    fn build(profile: &Profile, patch: &'a Patch) -> Self {
+
        let opened = Opened::from_patch(patch, profile);
+
        let mut revisions = patch
+
            .revisions()
+
            .skip(1) // skip the root revision since it's handled in `Opened::from_patch`
+
            .map(|(id, revision)| {
+
                (
+
                    revision.timestamp(),
+
                    RevisionEntry::from_revision(patch, id, revision, profile),
+
                )
+
            })
+
            .collect::<Vec<_>>();
+
        revisions.sort_by_key(|(t, _)| *t);
+
        Timeline {
+
            opened,
+
            revisions: revisions.into_iter().map(|(_, e)| e).collect(),
+
        }
+
    }
+

+
    fn into_lines(self, profile: &'a Profile) -> impl Iterator<Item = term::Line> + 'a {
+
        self.opened.into_lines(profile).chain(
+
            self.revisions
+
                .into_iter()
+
                .flat_map(|r| r.into_lines(profile)),
+
        )
+
    }
+
}
+

+
/// The root `Revision` of the `Patch`.
+
struct Opened<'a> {
+
    /// The `Author` of the patch.
+
    author: Author<'a>,
+
    /// When the patch was created.
+
    timestamp: cob::Timestamp,
+
    /// The commit head of the `Revision`.
+
    head: git::Oid,
+
    /// Any updates performed on the root `Revision`.
+
    updates: Vec<Update<'a>>,
+
}
+

+
impl<'a> Opened<'a> {
+
    fn from_patch(patch: &'a Patch, profile: &Profile) -> Self {
+
        let (root, revision) = patch.root();
+
        let mut updates = Vec::new();
+
        updates.extend(revision.reviews().map(|(_, review)| {
+
            (
+
                review.timestamp(),
+
                Update::Reviewed {
+
                    review: review.clone(),
+
                },
+
            )
+
        }));
+
        updates.extend(patch.merges().filter_map(|(_, merge)| {
+
            if merge.revision == root {
+
                Some((
+
                    merge.timestamp,
+
                    Update::Merged {
+
                        author: Author::new(&revision.author().id, profile),
+
                        merge: merge.clone(),
+
                    },
+
                ))
+
            } else {
+
                None
+
            }
+
        }));
+
        updates.sort_by_key(|(t, _)| *t);
+
        Opened {
+
            author: Author::new(&patch.author().id, profile),
+
            timestamp: patch.timestamp(),
+
            head: revision.head(),
+
            updates: updates.into_iter().map(|(_, up)| up).collect(),
+
        }
+
    }
+

+
    fn into_lines(self, profile: &'a Profile) -> impl Iterator<Item = term::Line> + 'a {
+
        iter::once(
+
            term::Line::spaced([
+
                term::format::positive("●").into(),
+
                term::format::default("opened by").into(),
+
            ])
+
            .space()
+
            .extend(self.author.line())
+
            .space()
+
            .extend(term::Line::spaced([
+
                term::format::parens(term::format::secondary(term::format::oid(self.head))).into(),
+
                term::format::dim(term::format::timestamp(self.timestamp)).into(),
+
            ])),
+
        )
+
        .chain(self.updates.into_iter().map(|up| {
+
            term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
+
                .extend(up.into_line(profile))
+
        }))
+
    }
+
}
+

+
/// A revision entry in the [`Timeline`].
+
enum RevisionEntry<'a> {
+
    /// An `Updated` entry means that the original author of the
+
    /// `Patch` created a new revision.
+
    Updated {
+
        /// When the `Revision` was created.
+
        timestamp: cob::Timestamp,
+
        /// The id of the `Revision`.
+
        id: RevisionId,
+
        /// The commit head of the `Revision`.
+
        head: git::Oid,
+
        /// All [`Update`]s that occurred on the `Revision`.
+
        updates: Vec<Update<'a>>,
+
    },
+
    /// A `Revised` entry means that an author other than the original
+
    /// author of the `Patch` created a new revision.
+
    Revised {
+
        /// The `Author` that created the `Revision` (that is not the
+
        /// `Patch` author).
+
        author: Author<'a>,
+
        /// When the `Revision` was created.
+
        timestamp: cob::Timestamp,
+
        /// The id of the `Revision`.
+
        id: RevisionId,
+
        /// The commit head of the `Revision`.
+
        head: git::Oid,
+
        /// All [`Update`]s that occurred on the `Revision`.
+
        updates: Vec<Update<'a>>,
+
    },
+
}
+

+
impl<'a> RevisionEntry<'a> {
+
    fn from_revision(
+
        patch: &Patch,
+
        id: RevisionId,
+
        revision: &'a Revision,
+
        profile: &Profile,
+
    ) -> Self {
+
        let mut updates = Vec::new();
+
        updates.extend(revision.reviews().map(|(_, review)| {
+
            (
+
                review.timestamp(),
+
                Update::Reviewed {
+
                    review: review.clone(),
+
                },
+
            )
+
        }));
+
        updates.extend(patch.merges().filter_map(|(_, merge)| {
+
            if merge.revision == id {
+
                Some((
+
                    merge.timestamp,
+
                    Update::Merged {
+
                        author: Author::new(&revision.author().id, profile),
+
                        merge: merge.clone(),
+
                    },
+
                ))
+
            } else {
+
                None
+
            }
+
        }));
+
        updates.sort_by_key(|(t, _)| *t);
+

+
        if revision.author() == patch.author() {
+
            RevisionEntry::Updated {
+
                timestamp: revision.timestamp(),
+
                id,
+
                head: revision.head(),
+
                updates: updates.into_iter().map(|(_, up)| up).collect(),
+
            }
+
        } else {
+
            RevisionEntry::Revised {
+
                author: Author::new(&revision.author().id, profile),
+
                timestamp: revision.timestamp(),
+
                id,
+
                head: revision.head(),
+
                updates: updates.into_iter().map(|(_, up)| up).collect(),
+
            }
+
        }
+
    }
+

+
    fn into_lines(self, profile: &'a Profile) -> Vec<term::Line> {
+
        match self {
+
            RevisionEntry::Updated {
+
                timestamp,
+
                id,
+
                head,
+
                updates,
+
            } => Self::updated(profile, timestamp, id, head, updates).collect(),
+
            RevisionEntry::Revised {
+
                author,
+
                timestamp,
+
                id,
+
                head,
+
                updates,
+
            } => Self::revised(profile, author, timestamp, id, head, updates).collect(),
+
        }
+
    }
+

+
    fn updated(
+
        profile: &'a Profile,
+
        timestamp: cob::Timestamp,
+
        id: RevisionId,
+
        head: git::Oid,
+
        updates: Vec<Update<'a>>,
+
    ) -> impl Iterator<Item = term::Line> + 'a {
+
        iter::once(term::Line::spaced([
+
            term::format::tertiary("↑").into(),
+
            term::format::default("updated to").into(),
+
            term::format::dim(id).into(),
+
            term::format::parens(term::format::secondary(term::format::oid(head))).into(),
+
            term::format::dim(term::format::timestamp(timestamp)).into(),
+
        ]))
+
        .chain(updates.into_iter().map(|up| {
+
            term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
+
                .extend(up.into_line(profile))
+
        }))
+
    }
+

+
    fn revised(
+
        profile: &'a Profile,
+
        author: Author<'a>,
+
        timestamp: cob::Timestamp,
+
        id: RevisionId,
+
        head: git::Oid,
+
        updates: Vec<Update<'a>>,
+
    ) -> impl Iterator<Item = term::Line> + 'a {
+
        let (alias, nid) = author.labels();
+
        iter::once(term::Line::spaced([
+
            term::format::tertiary("*").into(),
+
            term::format::default("revised by").into(),
+
            alias,
+
            nid,
+
            term::format::default("in").into(),
+
            term::format::dim(term::format::oid(id)).into(),
+
            term::format::parens(term::format::secondary(term::format::oid(head))).into(),
+
            term::format::dim(term::format::timestamp(timestamp)).into(),
+
        ]))
+
        .chain(updates.into_iter().map(|up| {
+
            term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
+
                .extend(up.into_line(profile))
+
        }))
+
    }
+
}
+

+
/// An update in the [`Patch`]'s timeline.
+
enum Update<'a> {
+
    /// A revision of the patch was reviewed.
+
    Reviewed { review: Review },
+
    /// A revision of the patch was merged.
+
    Merged { author: Author<'a>, merge: Merge },
+
}
+

+
impl<'a> Update<'a> {
+
    fn timestamp(&self) -> cob::Timestamp {
+
        match self {
+
            Update::Reviewed { review } => review.timestamp(),
+
            Update::Merged { merge, .. } => merge.timestamp,
+
        }
+
    }
+

+
    fn into_line(self, profile: &Profile) -> term::Line {
+
        let timestamp = self.timestamp();
+
        let mut line = match self {
+
            Update::Reviewed { review } => {
+
                let verdict = review.verdict();
+
                let verdict_symbol = match verdict {
+
                    Some(Verdict::Accept) => term::format::positive("✓"),
+
                    Some(Verdict::Reject) => term::format::negative("✗"),
+
                    None => term::format::dim("⋄"),
+
                };
+
                let verdict_verb = match verdict {
+
                    Some(Verdict::Accept) => term::format::default("accepted"),
+
                    Some(Verdict::Reject) => term::format::default("rejected"),
+
                    None => term::format::default("reviewed"),
+
                };
+
                term::Line::spaced([
+
                    verdict_symbol.into(),
+
                    verdict_verb.into(),
+
                    term::format::default("by").into(),
+
                ])
+
                .space()
+
                .extend(Author::new(&review.author().id.into(), profile).line())
+
            }
+
            Update::Merged { author, merge } => {
+
                let (alias, nid) = author.labels();
+
                term::Line::spaced([
+
                    term::format::primary("✓").bold().into(),
+
                    term::format::default("merged by").into(),
+
                    alias,
+
                    nid,
+
                    term::format::default("at revision").into(),
+
                    term::format::dim(term::format::oid(merge.revision)).into(),
+
                    term::format::parens(term::format::secondary(term::format::oid(merge.commit)))
+
                        .into(),
+
                ])
+
            }
+
        };
+
        line.push(term::Label::space());
+
        line.push(term::format::dim(term::format::timestamp(timestamp)));
+
        line
+
    }
+
}