Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: split into patch::create and patch::update
Slack Coder committed 3 years ago
commit 3c202564bf81a3a3ce5ad19e4625a6bb17fc9f9b
parent 215abde9e67241f7724d9d779d199bbdddfa7cb4
8 files changed +389 -310
modified radicle-cli/examples/rad-patch.md
@@ -89,18 +89,18 @@ Wait, lets add a README too! Just for fun.
```
$ touch README.md
$ git add README.md
-
$ git commit --message "add README for the YOLOs"
-
[flux-capacitor-power 6792455] add README for the YOLOs
+
$ git commit --message "Add README, just for the fun"
+
[flux-capacitor-power 27857ec] Add README, just for the fun
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 README.md
$ rad patch update --message "Add README, just for the fun" --no-confirm 15141cf1497627e2db54362972dd9533f62d1dcb

-
🌱 Creating patch for heartwood
+
🌱 Updating patch for heartwood

ok Pushing HEAD to storage...
ok Analyzing remotes...

-
15141cf1497 R0 (3e674d1) -> R1 (6792455)
+
15141cf1497 R0 (3e674d1) -> R1 (27857ec)
1 commit(s) ahead, 0 commit(s) behind


modified radicle-cli/src/commands/comment.rs
@@ -12,7 +12,7 @@ use radicle::storage::WriteStorage;

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

pub const HELP: Help = Help {
    name: "comment",
@@ -33,7 +33,7 @@ Options
#[derive(Debug)]
pub struct Options {
    pub id: cob::ObjectId,
-
    pub message: Comment,
+
    pub message: Message,
}

#[inline]
@@ -50,19 +50,19 @@ impl Args for Options {

        let mut parser = lexopt::Parser::from_args(args);
        let mut id: Option<cob::ObjectId> = None;
-
        let mut message = Comment::default();
+
        let mut message = Message::default();

        while let Some(arg) = parser.next()? {
            match arg {
                // Options.
                Long("message") | Short('m') => {
-
                    if message != Comment::Blank {
+
                    if message != Message::Blank {
                        // We skip this code when `no-message` is specified.
                        let txt: String = parser.value()?.to_string_lossy().into();
                        message.append(&txt);
                    }
                }
-
                Long("no-message") => message = Comment::Blank,
+
                Long("no-message") => message = Message::Blank,

                // Common.
                Long("help") => return Err(Error::Help.into()),
modified radicle-cli/src/commands/patch.rs
@@ -6,6 +6,8 @@ mod create;
mod list;
#[path = "patch/show.rs"]
mod show;
+
#[path = "patch/update.rs"]
+
mod update;

use std::ffi::OsString;

@@ -16,7 +18,7 @@ use radicle::prelude::*;

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

pub const HELP: Help = Help {
    name: "patch",
@@ -43,23 +45,6 @@ Options
"#,
};

-
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
-
pub enum OptPatch {
-
    #[default]
-
    Any,
-
    None,
-
    Patch(PatchId),
-
}
-

-
impl From<OptPatch> for Option<PatchId> {
-
    fn from(opt: OptPatch) -> Self {
-
        match opt {
-
            OptPatch::Patch(patch_id) => Some(patch_id),
-
            _ => None,
-
        }
-
    }
-
}
-

#[derive(Debug, Default, PartialEq, Eq)]
pub enum OperationName {
    Open,
@@ -72,14 +57,14 @@ pub enum OperationName {
#[derive(Debug)]
pub enum Operation {
    Open {
-
        message: Comment,
+
        message: Message,
    },
    Show {
        patch_id: PatchId,
    },
    Update {
-
        patch_id: OptPatch,
-
        message: Comment,
+
        patch_id: Option<PatchId>,
+
        message: Message,
    },
    List,
}
@@ -102,8 +87,8 @@ impl Args for Options {
        let mut op: Option<OperationName> = None;
        let mut verbose = false;
        let mut sync = true;
-
        let mut patch_id = OptPatch::default();
-
        let mut message = Comment::default();
+
        let mut patch_id = None;
+
        let mut message = Message::default();
        let mut push = true;

        while let Some(arg) = parser.next()? {
@@ -116,14 +101,14 @@ impl Args for Options {
                    confirm = false;
                }
                Long("message") | Short('m') => {
-
                    if message != Comment::Blank {
+
                    if message != Message::Blank {
                        // We skip this code when `no-message` is specified.
                        let txt: String = parser.value()?.to_string_lossy().into();
                        message.append(&txt);
                    }
                }
                Long("no-message") => {
-
                    message = Comment::Blank;
+
                    message = Message::Blank;
                }
                Long("sync") => {
                    // By default it is already true, so
@@ -156,11 +141,11 @@ impl Args for Options {

                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
                },
-
                Value(val) if op == Some(OperationName::Show) && patch_id == OptPatch::Any => {
-
                    patch_id = OptPatch::Patch(term::cob::parse_patch_id(val)?);
+
                Value(val) if op == Some(OperationName::Show) && patch_id.is_none() => {
+
                    patch_id = Some(term::cob::parse_patch_id(val)?);
                }
-
                Value(val) if op == Some(OperationName::Update) && patch_id == OptPatch::Any => {
-
                    patch_id = OptPatch::Patch(term::cob::parse_patch_id(val)?);
+
                Value(val) if op == Some(OperationName::Update) && patch_id.is_none() => {
+
                    patch_id = Some(term::cob::parse_patch_id(val)?);
                }
                _ => return Err(anyhow::anyhow!(arg.unexpected())),
            }
@@ -170,8 +155,7 @@ impl Args for Options {
            OperationName::Open => Operation::Open { message },
            OperationName::List => Operation::List,
            OperationName::Show => Operation::Show {
-
                patch_id: Option::from(patch_id)
-
                    .ok_or_else(|| anyhow!("a patch id must be provided"))?,
+
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch id must be provided"))?,
            },
            OperationName::Update => Operation::Update { patch_id, message },
        };
@@ -198,14 +182,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {

    match options.op {
        Operation::Open { ref message } => {
-
            create::run(
-
                &storage,
-
                &profile,
-
                &workdir,
-
                OptPatch::None,
-
                message.clone(),
-
                options,
-
            )?;
+
            create::run(&storage, &profile, &workdir, message.clone(), options)?;
        }
        Operation::List => {
            list::run(&storage, &profile, Some(workdir), options)?;
@@ -214,16 +191,16 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            show::run(&storage, &profile, &workdir, patch_id)?;
        }
        Operation::Update {
-
            ref patch_id,
+
            patch_id,
            ref message,
        } => {
-
            create::run(
+
            update::run(
                &storage,
                &profile,
                &workdir,
-
                *patch_id,
+
                patch_id,
                message.clone(),
-
                options,
+
                &options,
            )?;
        }
    }
modified radicle-cli/src/commands/patch/common.rs
@@ -1,13 +1,44 @@
+
use std::fmt;
+
use std::path::Path;
+

+
use anyhow::anyhow;
+

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

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

+
use super::Options;
+

+
/// Give the name of the branch or an appropriate error.
+
#[inline]
+
pub fn branch_name<'a>(branch: &'a git::raw::Branch) -> anyhow::Result<&'a str> {
+
    branch
+
        .name()?
+
        .ok_or(anyhow!("head branch must be valid UTF-8"))
+
}
+

+
/// 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]
+
pub fn confirm<D: fmt::Display>(prompt: D, options: &Options) -> bool {
+
    !options.confirm || term::confirm(prompt)
+
}
+

/// List of merge targets.
#[derive(Debug, Default)]
pub struct MergeTargets {
@@ -40,6 +71,38 @@ pub fn find_merge_targets(
    Ok(targets)
}

+
/// Determine the merge target for this patch. This can ben any tracked remote's "default"
+
/// branch, as well as your own (eg. `rad/master`).
+
pub fn get_merge_target(
+
    project: &Project,
+
    storage: &Repository,
+
    head_branch: &git::raw::Branch,
+
) -> anyhow::Result<(storage::Remote, git::Oid)> {
+
    let head_oid = head_branch
+
        .get()
+
        .target()
+
        .ok_or(anyhow!("invalid HEAD ref; aborting"))?;
+
    let mut spinner = term::spinner("Analyzing remotes...");
+
    let targets = 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.");
+
            todo!("handle case without target");
+
        }
+
        [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();
+

+
    Ok((target_peer.clone(), *target_oid))
+
}
+

/// Return the [`Oid`] of the merge target.
pub fn patch_merge_target_oid(target: MergeTarget, repository: &Repository) -> anyhow::Result<Oid> {
    match target {
@@ -106,6 +169,60 @@ pub fn pretty_commit_version(
    Ok(oid)
}

+
#[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)
+
}
+

+
/// Push branch to the local storage.
+
///
+
/// The branch must be in storage for others to merge the `Patch`.
+
pub fn push_to_storage(
+
    storage: &Repository,
+
    head_branch: &git::raw::Branch,
+
    options: &Options,
+
) -> anyhow::Result<()> {
+
    let head_oid = branch_oid(head_branch)?;
+
    let mut spinner = term::spinner(format!(
+
        "Looking for HEAD ({}) in storage...",
+
        term::format::secondary(term::format::oid(head_oid))
+
    ));
+
    if storage.commit(head_oid).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 = match head_branch.upstream() {
+
            Ok(_) => git::run::<_, _, &str, &str>(Path::new("."), ["push", "rad"], [])?,
+
            Err(_) => git::run::<_, _, &str, &str>(
+
                Path::new("."),
+
                ["push", "--set-upstream", "rad", branch_name(head_branch)?],
+
                [],
+
            )?,
+
        };
+
        if options.verbose {
+
            spinner.finish();
+
            term::blob(output);
+
        }
+
    }
+
    spinner.finish();
+

+
    Ok(())
+
}
+

/// Find patches with a merge base equal to the one provided.
pub fn find_unmerged_with_base(
    patch_head: Oid,
modified radicle-cli/src/commands/patch/create.rs
@@ -1,20 +1,15 @@
-
use std::fmt;
-
use std::path::Path;
-

use anyhow::{anyhow, Context};

-
use radicle::cob::patch::{MergeTarget, PatchId, PatchMut, Patches};
+
use radicle::cob::patch;
use radicle::git;
-
use radicle::git::raw::Oid;
use radicle::prelude::*;
+
use radicle::storage;
use radicle::storage::git::Repository;

use crate::terminal as term;
-
use crate::terminal::args::Error;
-
use crate::terminal::patch;

-
use super::common;
-
use super::{OptPatch, Options};
+
use super::common::*;
+
use super::Options;

const PATCH_MSG: &str = r#"
<!--
@@ -28,24 +23,67 @@ and description.
-->
"#;

-
const REVISION_MSG: &str = r#"
-
<!--
-
Please enter a comment message for your patch update. Leaving this
-
blank is also okay.
-
-->
-
"#;
+
pub fn handle_patch_message(
+
    message: term::patch::Message,
+
    workdir: &git::raw::Repository,
+
    head_branch: &git::raw::Branch,
+
) -> anyhow::Result<(String, String)> {
+
    let head_oid = branch_oid(head_branch)?;
+
    let head_commit = workdir.find_commit(*head_oid)?;
+
    let commit_message = head_commit
+
        .message()
+
        .ok_or(anyhow!("commit summary is not valid UTF-8; aborting"))?;
+
    let message = message.get(&format!("{commit_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");
+
    }
+

+
    term::blank();
+
    term::patch::print_title_desc(title, &description);
+
    term::blank();

-
#[inline]
-
fn confirm<D: fmt::Display>(prompt: D, options: &Options) -> bool {
-
    !options.confirm || term::confirm(prompt)
+
    Ok((title.to_string(), description))
}

-
/// Give the name of the branch or an appropriate error.
-
#[inline]
-
fn branch_name<'a>(branch: &'a git::raw::Branch) -> anyhow::Result<&'a str> {
-
    branch
-
        .name()?
-
        .ok_or(anyhow!("head branch must be valid UTF-8"))
+
fn show_patch_commit_info(
+
    project: &Project,
+
    workdir: &git::raw::Repository,
+
    node_id: &NodeId,
+
    head_branch: &git::raw::Branch,
+
    target_peer: &storage::Remote,
+
    target_oid: git::Oid,
+
) -> anyhow::Result<()> {
+
    let head_oid = branch_oid(head_branch)?;
+
    // The merge base is basically the commit at which the histories diverge.
+
    let base_oid = workdir.merge_base(*target_oid, *head_oid)?;
+
    let commits = patch_commits(workdir, &base_oid, &head_oid)?;
+

+
    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(node_id)),
+
        term::format::highlight(branch_name(head_branch)?),
+
        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)?;
+

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

+
    Ok(())
}

/// Run patch creation.
@@ -53,207 +91,56 @@ pub fn run(
    storage: &Repository,
    profile: &Profile,
    workdir: &git::raw::Repository,
-
    patch_id: OptPatch,
-
    message: patch::Comment,
+
    message: term::patch::Message,
    options: Options,
) -> anyhow::Result<()> {
+
    let mut patches = patch::Patches::open(profile.public_key, storage)?;
    let project = storage.project_of(&profile.public_key).context(format!(
        "couldn't load project {} from local state",
        storage.id
    ))?;
+
    let head_branch = try_branch(workdir.head()?)?;

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

-
    let signer = term::signer(profile)?;
-
    let mut patches = Patches::open(profile.public_key, storage)?;
-

-
    // `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 = if head.is_branch() {
-
        git::raw::Branch::wrap(head)
-
    } else {
-
        anyhow::bail!("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 = match head_branch.upstream() {
-
            Ok(_) => git::run::<_, _, &str, &str>(Path::new("."), ["push", "rad"], [])?,
-
            Err(_) => git::run::<_, _, &str, &str>(
-
                Path::new("."),
-
                ["push", "--set-upstream", "rad", branch_name(&head_branch)?],
-
                [],
-
            )?,
-
        };
-
        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();
+
    push_to_storage(storage, &head_branch, &options)?;
+
    let (target_peer, target_oid) = get_merge_target(&project, storage, &head_branch)?;

    // 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 &patch_id {
-
        OptPatch::None => None,
-
        OptPatch::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, clock)) = 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, PatchMut::new(id, patch, clock, &mut patches)))
-
                } 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");
-
            }
-
        }
-
        OptPatch::Patch(id) => {
-
            if let Ok(patch) = patches.get_mut(id) {
-
                Some((*id, patch))
-
            } else {
-
                anyhow::bail!("Patch `{}` not found", id);
-
            }
-
        }
-
    };
-

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

-
            return update(
-
                patch, id, &base_oid, &head_oid, workdir, options, message, &signer,
-
            );
-
        } else {
-
            anyhow::bail!("Patch update aborted by user");
-
        }
-
    }
+
    show_patch_commit_info(
+
        &project,
+
        workdir,
+
        patches.public_key(),
+
        &head_branch,
+
        &target_peer,
+
        target_oid,
+
    )?;

    // 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(patches.public_key())),
-
        term::format::highlight(branch_name(&head_branch)?),
-
        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();
+
    let (title, description) = handle_patch_message(message, workdir, &head_branch)?;

    if !confirm("Continue?", &options) {
        anyhow::bail!("patch proposal aborted by user");
    }

-
    let commit_message = head_commit
-
        .message()
-
        .ok_or(anyhow!("commit summary is not valid UTF-8; aborting"))?;
-
    let message = message.get(&format!("{commit_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");
-
    }
-

-
    term::blank();
-
    term::patch::print_title_desc(title, &description);
-
    term::blank();
-

-
    if !confirm("Create patch?", &options) {
-
        anyhow::bail!("patch proposal aborted by user");
-
    }
-

+
    let head_oid = branch_oid(&head_branch)?;
+
    let base_oid = workdir.merge_base(*target_oid, *head_oid)?;
    let patch = patches.create(
        title,
        &description,
-
        MergeTarget::default(),
+
        patch::MergeTarget::default(),
        base_oid,
        head_oid,
        &[],
-
        &signer,
+
        &term::signer(profile)?,
    )?;

    term::blank();
@@ -265,53 +152,3 @@ pub fn run(

    Ok(())
}
-

-
/// Update an existing patch with a new revision.
-
fn update<G: Signer>(
-
    mut patch: PatchMut,
-
    patch_id: PatchId,
-
    base: &Oid,
-
    head: &Oid,
-
    workdir: &git::raw::Repository,
-
    options: Options,
-
    message: patch::Comment,
-
    signer: &G,
-
) -> anyhow::Result<()> {
-
    // TODO(cloudhead): Handle error.
-
    let (_, current_revision) = patch.latest().unwrap();
-
    let current_version = patch.version();
-

-
    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_version}")),
-
        term::format::secondary(term::format::oid(current_revision.oid)),
-
        term::format::dim(format!("R{}", current_version + 1)),
-
        term::format::secondary(term::format::oid(*head)),
-
    );
-
    let message = message.get(REVISION_MSG);
-

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

-
    if !confirm("Continue?", &options) {
-
        anyhow::bail!("patch update aborted by user");
-
    }
-
    patch.update(message, *base, *head, signer)?;
-

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

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

-
    Ok(())
-
}
added radicle-cli/src/commands/patch/update.rs
@@ -0,0 +1,148 @@
+
use anyhow::Context;
+

+
use radicle::cob::patch;
+
use radicle::git;
+
use radicle::prelude::*;
+
use radicle::storage::git::Repository;
+

+
use super::common::*;
+
use super::Options;
+
use crate::terminal as term;
+

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

+
fn select_patch(
+
    patches: &patch::Patches,
+
    workdir: &git::raw::Repository,
+
    head_branch: &git::raw::Branch,
+
    target_oid: git::Oid,
+
) -> anyhow::Result<patch::PatchId> {
+
    let head_oid = branch_oid(head_branch)?;
+
    let base_oid = workdir.merge_base(*target_oid, *head_oid)?;
+

+
    let mut spinner = term::spinner("Finding patches to update...");
+
    let mut result = find_unmerged_with_base(*head_oid, *target_oid, base_oid, patches, workdir)?;
+

+
    let Some((id, patch, _)) = result.pop() 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");
+
    };
+

+
    if !result.is_empty() {
+
        spinner.failed();
+
        term::blank();
+
        anyhow::bail!("More than one patch available to update, please specify an id with `rad patch --update <id>`");
+
    }
+
    spinner.message(format!(
+
        "Found existing patch {} {}",
+
        term::format::tertiary(term::format::cob(&id)),
+
        term::format::italic(patch.title())
+
    ));
+
    spinner.finish();
+
    term::blank();
+

+
    Ok(id)
+
}
+

+
fn show_update_commit_info(
+
    workdir: &git::raw::Repository,
+
    patch_id: &patch::PatchId,
+
    patch: &patch::Patch,
+
    current_revision: &patch::Revision,
+
    head_branch: &git::raw::Branch,
+
) -> anyhow::Result<()> {
+
    // TODO(cloudhead): Handle error.
+
    let current_version = patch.version();
+
    let head_oid = branch_oid(head_branch)?;
+

+
    term::blank();
+
    term::info!(
+
        "{} {} ({}) -> {} ({})",
+
        term::format::tertiary(term::format::cob(patch_id)),
+
        term::format::dim(format!("R{current_version}")),
+
        term::format::secondary(term::format::oid(current_revision.oid)),
+
        term::format::dim(format!("R{}", current_version + 1)),
+
        term::format::secondary(term::format::oid(*head_oid)),
+
    );
+

+
    // Difference between the two revisions.
+
    let head_oid = branch_oid(head_branch)?;
+
    term::patch::print_commits_ahead_behind(workdir, *head_oid, *current_revision.oid)?;
+
    term::blank();
+

+
    Ok(())
+
}
+

+
/// Run patch creation.
+
pub fn run(
+
    storage: &Repository,
+
    profile: &Profile,
+
    workdir: &git::raw::Repository,
+
    patch_id: Option<patch::PatchId>,
+
    message: term::patch::Message,
+
    options: &Options,
+
) -> anyhow::Result<()> {
+
    let project = storage.project_of(&profile.public_key).context(format!(
+
        "couldn't load project {} from local state",
+
        storage.id
+
    ))?;
+
    // `HEAD`; This is what we are proposing as a patch.
+
    let head_branch = try_branch(workdir.head()?)?;
+

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

+
    push_to_storage(storage, &head_branch, options)?;
+

+
    let (_, target_oid) = get_merge_target(&project, storage, &head_branch)?;
+
    let mut patches = patch::Patches::open(profile.public_key, storage)?;
+

+
    let patch_id = match patch_id {
+
        Some(patch_id) => patch_id,
+
        None => select_patch(&patches, workdir, &head_branch, target_oid)?,
+
    };
+
    let Ok(mut patch) = patches.get_mut(&patch_id) else {
+
        anyhow::bail!("Patch `{patch_id}` not found");
+
    };
+

+
    if !confirm("Update patch?", options) {
+
        anyhow::bail!("Patch update aborted by user");
+
    }
+

+
    // TODO(cloudhead): Handle error.
+
    let (_, current_revision) = patch.latest().unwrap();
+
    if *current_revision.oid == *branch_oid(&head_branch)? {
+
        term::info!("Nothing to do, patch is already up to date.");
+
        return Ok(());
+
    }
+

+
    show_update_commit_info(workdir, &patch_id, &patch, current_revision, &head_branch)?;
+

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

+
    let head_oid = branch_oid(&head_branch)?;
+
    let base_oid = workdir.merge_base(*target_oid, *head_oid)?;
+
    let message = message.get(REVISION_MSG);
+
    let signer = term::signer(profile)?;
+
    patch.update(message, base_oid, *head_oid, &signer)?;
+

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

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

+
    Ok(())
+
}
modified radicle-cli/src/commands/review.rs
@@ -9,7 +9,7 @@ use radicle::rad;

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

pub const HELP: Help = Help {
    name: "review",
@@ -47,7 +47,7 @@ Markdown supported.
pub struct Options {
    pub id: PatchId,
    pub revision: Option<RevisionIx>,
-
    pub message: Comment,
+
    pub message: Message,
    pub sync: bool,
    pub verbose: bool,
    pub verdict: Option<Verdict>,
@@ -60,7 +60,7 @@ impl Args for Options {
        let mut parser = lexopt::Parser::from_args(args);
        let mut id: Option<PatchId> = None;
        let mut revision: Option<RevisionIx> = None;
-
        let mut message = Comment::default();
+
        let mut message = Message::default();
        let mut sync = true;
        let mut verbose = false;
        let mut verdict = None;
@@ -85,13 +85,13 @@ impl Args for Options {
                    sync = false;
                }
                Long("message") | Short('m') => {
-
                    if message != Comment::Blank {
+
                    if message != Message::Blank {
                        let txt: String = parser.value()?.to_string_lossy().into();
                        message.append(&txt);
                    }
                }
                Long("no-message") => {
-
                    message = Comment::Blank;
+
                    message = Message::Blank;
                }
                Long("verbose") | Short('v') => {
                    verbose = true;
modified radicle-cli/src/terminal/patch.rs
@@ -2,9 +2,9 @@ use radicle::git;

use crate::terminal as term;

-
/// How a comment is to be supplied by the user for a patch or issue on the terminal.
+
/// The user supplied `Patch` description.
#[derive(Clone, Debug, PartialEq, Eq)]
-
pub enum Comment {
+
pub enum Message {
    /// Prompt user to write comment in editor.
    Edit,
    /// Don't leave a comment.
@@ -13,18 +13,18 @@ pub enum Comment {
    Text(String),
}

-
impl Comment {
-
    /// Get the comment as a string according to the method.
+
impl Message {
+
    /// Get the `Message` as a string according to the method.
    pub fn get(self, help: &str) -> String {
        let comment = match self {
-
            Comment::Edit => term::Editor::new()
+
            Message::Edit => term::Editor::new()
                .require_save(true)
                .trim_newlines(true)
                .extension(".markdown")
                .edit(help)
                .unwrap(),
-
            Comment::Blank => None,
-
            Comment::Text(c) => Some(c),
+
            Message::Blank => None,
+
            Message::Text(c) => Some(c),
        };
        let comment = comment.unwrap_or_default().replace(help, "");
        let comment = comment.trim();
@@ -33,15 +33,15 @@ impl Comment {
    }

    pub fn append(&mut self, arg: &str) {
-
        if let Comment::Text(v) = self {
+
        if let Message::Text(v) = self {
            v.extend(["\n\n", arg]);
        } else {
-
            *self = Comment::Text(arg.into());
+
            *self = Message::Text(arg.into());
        };
    }
}

-
impl Default for Comment {
+
impl Default for Message {
    fn default() -> Self {
        Self::Edit
    }