Add `rad-merge` command
Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
parent
01047a4942
commit
b4bc032113
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue