Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add `rad-merge` command
Alexis Sellier committed 3 years ago
commit b4bc032113ac663575f45c24421804b60737da7e
parent 01047a494201e8159dfb553da8cec9dc56817b7a
3 files changed +389 -1
modified radicle-cli/src/commands.rs
@@ -16,6 +16,8 @@ pub mod rad_inspect;
pub mod rad_issue;
#[path = "commands/ls.rs"]
pub mod rad_ls;
+
#[path = "commands/merge.rs"]
+
pub mod rad_merge;
#[path = "commands/patch.rs"]
pub mod rad_patch;
#[path = "commands/push.rs"]
added radicle-cli/src/commands/merge.rs
@@ -0,0 +1,385 @@
+
use std::ffi::OsString;
+
use std::fmt;
+
use std::fmt::Write;
+
use std::str::FromStr;
+

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

+
use crate::terminal as term;
+
use crate::terminal::args::{Args, Error, Help};
+
use radicle::cob;
+
use radicle::cob::patch::RevisionIx;
+
use radicle::cob::patch::{Patch, PatchId};
+
use radicle::git;
+
use radicle::prelude::*;
+
use radicle::rad;
+

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

+
    rad merge [<id>] [<option>...]
+

+
    To specify a patch to merge, use the fully qualified patch id.
+

+
Options
+

+
    -i, --interactive         Ask for confirmations
+
    -r, --revision <number>   Revision number to merge, defaults to the latest
+
        --help                Print help
+
"#,
+
};
+

+
/// Merge commit help message.
+
const MERGE_HELP_MSG: &str = r#"
+
# Check the commit message for this merge and make sure everything looks good,
+
# or make any necessary change.
+
#
+
# Lines starting with '#' will be ignored, and an empty message aborts the commit.
+
#
+
# vim: ft=gitcommit
+
#
+
"#;
+

+
/// A patch merge style.
+
#[derive(Debug, PartialEq, Eq)]
+
pub enum MergeStyle {
+
    /// A merge commit is created.
+
    Commit,
+
    /// The branch is fast-forwarded to the patch's commit.
+
    FastForward,
+
}
+

+
impl fmt::Display for MergeStyle {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self {
+
            Self::Commit => {
+
                write!(f, "merge-commit")
+
            }
+
            Self::FastForward => {
+
                write!(f, "fast-forward")
+
            }
+
        }
+
    }
+
}
+

+
#[derive(PartialEq, Eq)]
+
pub enum State {
+
    Open,
+
    Merged,
+
}
+
#[derive(Debug)]
+
pub struct Options {
+
    pub id: PatchId,
+
    pub interactive: bool,
+
    pub revision: Option<RevisionIx>,
+
}
+

+
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 id: Option<PatchId> = None;
+
        let mut revision: Option<RevisionIx> = None;
+
        let mut interactive = false;
+

+
        while let Some(arg) = parser.next()? {
+
            match arg {
+
                Long("help") => {
+
                    return Err(Error::Help.into());
+
                }
+
                Long("interactive") | Short('i') => {
+
                    interactive = true;
+
                }
+
                Long("revision") | Short('r') => {
+
                    let value = parser.value()?;
+
                    let id =
+
                        RevisionIx::from_str(value.to_str().unwrap_or_default()).map_err(|_| {
+
                            anyhow!("invalid revision number `{}`", value.to_string_lossy())
+
                        })?;
+
                    revision = Some(id);
+
                }
+
                Value(val) => {
+
                    let val = val
+
                        .to_str()
+
                        .ok_or_else(|| anyhow!("patch id specified is not UTF-8"))?;
+

+
                    id = Some(
+
                        PatchId::from_str(val)
+
                            .map_err(|_| anyhow!("invalid patch id '{}'", val))?,
+
                    );
+
                }
+
                _ => return Err(anyhow::anyhow!(arg.unexpected())),
+
            }
+
        }
+

+
        Ok((
+
            Options {
+
                id: id.ok_or_else(|| anyhow!("a patch id to merge must be provided"))?,
+
                interactive,
+
                revision,
+
            },
+
            vec![],
+
        ))
+
    }
+
}
+

+
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
    //
+
    // Setup
+
    //
+
    let (repo, id) =
+
        rad::cwd().map_err(|_| anyhow!("this command must be run in the context of a project"))?;
+
    let profile = ctx.profile()?;
+
    let signer = term::signer(&profile)?;
+
    let repository = profile.storage.repository(id)?;
+
    let _project = repository
+
        .project_of(profile.id())
+
        .context(format!("couldn't load project {} from local state", id))?;
+
    let repository = profile.storage.repository(id)?;
+
    let cobs = cob::Store::open(*profile.id(), &repository)?;
+
    let patches = cobs.patches();
+

+
    if repo.head_detached()? {
+
        anyhow::bail!("HEAD is in a detached state; can't merge");
+
    }
+

+
    //
+
    // Get patch information
+
    //
+
    let patch_id = options.id;
+
    let patch = patches
+
        .get(&patch_id)?
+
        .ok_or_else(|| anyhow!("couldn't find patch {} locally", &options.id))?;
+

+
    let head = repo.head()?;
+
    let branch = head
+
        .shorthand()
+
        .ok_or_else(|| anyhow!("invalid head branch"))?;
+
    let head_oid = head
+
        .target()
+
        .ok_or_else(|| anyhow!("cannot merge into detatched head; aborting"))?;
+
    let revision_id = options.revision.unwrap_or_else(|| patch.version());
+
    let revision = patch
+
        .revisions
+
        .get(revision_id)
+
        .ok_or_else(|| anyhow!("revision R{} does not exist", revision_id))?;
+

+
    //
+
    // Analyze merge
+
    //
+
    let patch_commit = repo
+
        .find_annotated_commit(revision.oid.into())
+
        .context("patch head not found in local repository")?;
+
    let (merge, _merge_pref) = repo.merge_analysis(&[&patch_commit])?;
+

+
    let merge_style = if merge.is_fast_forward() {
+
        // The given merge input is a fast-forward from HEAD and no merge needs to be performed.
+
        // Instead, the client can apply the input commits to its HEAD.
+
        MergeStyle::FastForward
+
    } else if merge.is_normal() {
+
        // A “normal” merge; both HEAD and the given merge input have diverged from their common
+
        // ancestor. The divergent commits must be merged.
+
        //
+
        // Let's check if there are potential merge conflicts.
+
        let our_commit = head.peel_to_commit()?;
+
        let their_commit = repo.find_commit(revision.oid.into())?;
+

+
        let index = repo
+
            .merge_commits(&our_commit, &their_commit, None)
+
            .context("failed to perform merge analysis")?;
+

+
        if index.has_conflicts() {
+
            return Err(Error::WithHint {
+
                err: anyhow!("patch conflicts with {}", branch),
+
                hint: "Patch must be rebased before it can be merged.",
+
            }
+
            .into());
+
        }
+
        MergeStyle::Commit
+
    } else if merge.is_up_to_date() {
+
        term::info!(
+
            "✓ Patch {} is already part of {}",
+
            term::format::tertiary(patch_id),
+
            term::format::highlight(branch)
+
        );
+

+
        return Ok(());
+
    } else if merge.is_unborn() {
+
        anyhow::bail!("HEAD does not point to a valid commit");
+
    } else {
+
        anyhow::bail!(
+
            "no merge is possible between {} and {}",
+
            head_oid,
+
            revision.oid
+
        );
+
    };
+

+
    let merge_style_pretty = match merge_style {
+
        MergeStyle::FastForward => term::format::style(merge_style.to_string())
+
            .dim()
+
            .italic()
+
            .to_string(),
+
        MergeStyle::Commit => term::format::style(merge_style.to_string())
+
            .yellow()
+
            .italic()
+
            .to_string(),
+
    };
+

+
    term::info!(
+
        "{} {} {} ({}) by {} into {} ({}) via {}...",
+
        term::format::bold("Merging"),
+
        term::format::tertiary(term::format::cob(&patch_id)),
+
        term::format::dim(format!("R{}", revision_id)),
+
        term::format::secondary(term::format::oid(revision.oid)),
+
        term::format::tertiary(patch.author.id),
+
        term::format::highlight(branch),
+
        term::format::secondary(term::format::oid(head_oid)),
+
        merge_style_pretty
+
    );
+

+
    if options.interactive && !term::confirm("Confirm?") {
+
        anyhow::bail!("merge aborted by user");
+
    }
+

+
    //
+
    // Perform merge
+
    //
+
    match merge_style {
+
        MergeStyle::Commit => {
+
            merge_commit(&repo, patch_id, &patch_commit, &patch, cobs.public_key())?;
+
        }
+
        MergeStyle::FastForward => {
+
            fast_forward(&repo, &revision.oid)?;
+
        }
+
    }
+

+
    term::success!(
+
        "Updated {} {} -> {} via {}",
+
        term::format::highlight(branch),
+
        term::format::secondary(term::format::oid(head_oid)),
+
        term::format::secondary(term::format::oid(revision.oid)),
+
        merge_style_pretty
+
    );
+

+
    //
+
    // Update patch COB
+
    //
+
    // TODO: Don't allow merging the same revision twice?
+
    patches.merge(&patch_id, revision_id, head_oid.into(), &signer)?;
+

+
    term::success!(
+
        "Patch state updated, use {} to publish",
+
        term::format::secondary("`rad push`")
+
    );
+

+
    Ok(())
+
}
+

+
// Perform git merge.
+
//
+
// This does not touch the COB state.
+
//
+
// Nb. Merge can fail even though conflicts were not detected if there are some
+
// files in the repo that are not checked in. This is because the merge conflict
+
// simulation only operates on the commits, not the checkout.
+
fn merge_commit(
+
    repo: &git::raw::Repository,
+
    patch_id: PatchId,
+
    patch_commit: &git::raw::AnnotatedCommit,
+
    patch: &Patch,
+
    whoami: &PublicKey,
+
) -> anyhow::Result<()> {
+
    let description = patch.description().trim();
+
    let mut merge_opts = git::raw::MergeOptions::new();
+
    let mut merge_msg = format!(
+
        "Merge patch '{}' from {}",
+
        term::format::cob(&patch_id),
+
        patch.author.id()
+
    );
+
    write!(&mut merge_msg, "\n\n")?;
+

+
    if !description.is_empty() {
+
        write!(&mut merge_msg, "{}", patch.description().trim())?;
+
        write!(&mut merge_msg, "\n\n")?;
+
    }
+
    writeln!(&mut merge_msg, "Rad-Patch: {}", patch_id)?;
+
    writeln!(&mut merge_msg, "Rad-Author: {}", patch.author.id())?;
+
    writeln!(&mut merge_msg, "Rad-Committer: {}", whoami)?;
+
    writeln!(&mut merge_msg)?;
+
    writeln!(&mut merge_msg, "{}", MERGE_HELP_MSG.trim())?;
+

+
    // Offer user the chance to edit the message before committing.
+
    let merge_msg = match term::Editor::new()
+
        .require_save(true)
+
        .trim_newlines(true)
+
        .extension(".git-commit")
+
        .edit(&merge_msg)
+
        .unwrap()
+
    {
+
        Some(s) => s
+
            .lines()
+
            .filter(|l| !l.starts_with('#'))
+
            .collect::<Vec<_>>()
+
            .join("\n"),
+
        None => anyhow::bail!("user aborted merge"),
+
    };
+

+
    // Empty message aborts merge.
+
    if merge_msg.trim().is_empty() {
+
        anyhow::bail!("user aborted merge");
+
    }
+

+
    // Perform merge (nb. this does not commit).
+
    repo.merge(&[patch_commit], Some(merge_opts.patience(true)), None)
+
        .context("merge failed")?;
+

+
    // Commit staged changes.
+
    let commit = repo.find_commit(patch_commit.id())?;
+
    let author = commit.author();
+
    let committer = repo
+
        .signature()
+
        .context("git user name or email not configured")?;
+

+
    let tree = repo.index()?.write_tree()?;
+
    let tree = repo.find_tree(tree)?;
+
    let parents = &[&repo.head()?.peel_to_commit()?, &commit];
+

+
    repo.commit(
+
        Some("HEAD"),
+
        &author,
+
        &committer,
+
        &merge_msg,
+
        &tree,
+
        parents,
+
    )
+
    .context("merge commit failed")?;
+

+
    // Cleanup merge state.
+
    repo.cleanup_state().context("merge state cleanup failed")?;
+

+
    Ok(())
+
}
+

+
/// Perform fast-forward merge of patch.
+
fn fast_forward(repo: &git::raw::Repository, patch_oid: &git::Oid) -> anyhow::Result<()> {
+
    let oid = patch_oid.to_string();
+
    let args = ["merge", "--ff-only", &oid];
+

+
    term::subcommand(format!("git {}", args.join(" ")));
+
    let output = git::run::<_, _, &str, &str>(
+
        repo.workdir()
+
            .ok_or_else(|| anyhow!("cannot fast-forward in bare repo"))?,
+
        args,
+
        [],
+
    )
+
    .context("fast-forward failed")?;
+

+
    term::blob(output);
+

+
    Ok(())
+
}
modified radicle-cli/src/terminal/format.rs
@@ -1,6 +1,7 @@
use std::{fmt, time};

-
use dialoguer::console::style;
+
pub use dialoguer::console::style;
+

use radicle::cob::Timestamp;
use radicle::node::NodeId;
use radicle::profile::Profile;