Implement `rad-patch`
Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
parent
dea268c389
commit
47d20003c0
|
|
@ -1579,6 +1579,7 @@ dependencies = [
|
||||||
"radicle-crypto",
|
"radicle-crypto",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
"timeago",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -2242,6 +2243,12 @@ dependencies = [
|
||||||
"time-core",
|
"time-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "timeago"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ec32dde57efb15c035ac074118d7f32820451395f28cb0524a01d4e94983b26"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ lexopt = { version = "0.2" }
|
||||||
log = { version = "0.4", features = ["std"] }
|
log = { version = "0.4", features = ["std"] }
|
||||||
serde_json = { version = "1" }
|
serde_json = { version = "1" }
|
||||||
thiserror = { version = "1" }
|
thiserror = { version = "1" }
|
||||||
|
timeago = { version = "0.3", default-features = false }
|
||||||
zeroize = { version = "1.1" }
|
zeroize = { version = "1.1" }
|
||||||
|
|
||||||
[dependencies.radicle]
|
[dependencies.radicle]
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ pub mod rad_init;
|
||||||
pub mod rad_inspect;
|
pub mod rad_inspect;
|
||||||
#[path = "commands/ls.rs"]
|
#[path = "commands/ls.rs"]
|
||||||
pub mod rad_ls;
|
pub mod rad_ls;
|
||||||
|
#[path = "commands/patch.rs"]
|
||||||
|
pub mod rad_patch;
|
||||||
#[path = "commands/push.rs"]
|
#[path = "commands/push.rs"]
|
||||||
pub mod rad_push;
|
pub mod rad_push;
|
||||||
#[path = "commands/rm.rs"]
|
#[path = "commands/rm.rs"]
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
let Ok(repo) = storage.repository(id) else { return };
|
let Ok(repo) = storage.repository(id) else { return };
|
||||||
let Ok((_, head)) = repo.head() else { return };
|
let Ok((_, head)) = repo.head() else { return };
|
||||||
let Ok(Doc { payload, .. }) = repo.project_of(profile.id()) else { return };
|
let Ok(Doc { payload, .. }) = repo.project_of(profile.id()) else { return };
|
||||||
let head = term::format::oid(&head);
|
let head = term::format::oid(head);
|
||||||
table.push([
|
table.push([
|
||||||
term::format::bold(payload.name),
|
term::format::bold(payload.name),
|
||||||
term::format::tertiary(id),
|
term::format::tertiary(id),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
#[path = "patch/common.rs"]
|
||||||
|
mod common;
|
||||||
|
#[path = "patch/create.rs"]
|
||||||
|
mod create;
|
||||||
|
#[path = "patch/list.rs"]
|
||||||
|
mod list;
|
||||||
|
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
|
||||||
|
use radicle::cob::patch::PatchId;
|
||||||
|
use radicle::prelude::*;
|
||||||
|
|
||||||
|
use crate::terminal as term;
|
||||||
|
use crate::terminal::args::{Args, Error, Help};
|
||||||
|
use crate::terminal::patch::Comment;
|
||||||
|
|
||||||
|
pub const HELP: Help = Help {
|
||||||
|
name: "patch",
|
||||||
|
description: "Work with radicle patches",
|
||||||
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
|
usage: r#"
|
||||||
|
Usage
|
||||||
|
|
||||||
|
rad patch [<option>...]
|
||||||
|
|
||||||
|
Create options
|
||||||
|
|
||||||
|
-u, --update [<id>] Update an existing patch (default: no)
|
||||||
|
--[no-]sync Sync patch to seed (default: sync)
|
||||||
|
--[no-]push Push patch head to storage (default: true)
|
||||||
|
-m, --message [<string>] Provide a comment message to the patch or revision (default: prompt)
|
||||||
|
--no-message Leave the patch or revision comment message blank
|
||||||
|
|
||||||
|
Options
|
||||||
|
|
||||||
|
-l, --list List all patches (default: false)
|
||||||
|
--help Print help
|
||||||
|
"#,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Update {
|
||||||
|
No,
|
||||||
|
Any,
|
||||||
|
Patch(PatchId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Update {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::No
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct Options {
|
||||||
|
pub list: bool,
|
||||||
|
pub verbose: bool,
|
||||||
|
pub sync: bool,
|
||||||
|
pub push: bool,
|
||||||
|
pub update: Update,
|
||||||
|
pub message: Comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Args for Options {
|
||||||
|
fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
|
||||||
|
use lexopt::prelude::*;
|
||||||
|
|
||||||
|
let mut parser = lexopt::Parser::from_args(args);
|
||||||
|
let mut list = false;
|
||||||
|
let mut verbose = false;
|
||||||
|
let mut sync = true;
|
||||||
|
let mut message = Comment::default();
|
||||||
|
let mut push = true;
|
||||||
|
let mut update = Update::default();
|
||||||
|
|
||||||
|
while let Some(arg) = parser.next()? {
|
||||||
|
match arg {
|
||||||
|
// Operations.
|
||||||
|
Long("list") | Short('l') => {
|
||||||
|
list = true;
|
||||||
|
}
|
||||||
|
Long("update") | Short('u') => {
|
||||||
|
if let Ok(val) = parser.value() {
|
||||||
|
let val = val
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| anyhow!("patch id specified is not UTF-8"))?;
|
||||||
|
let id = PatchId::from_str(val)
|
||||||
|
.map_err(|_| anyhow!("invalid patch id '{}'", val))?;
|
||||||
|
|
||||||
|
update = Update::Patch(id);
|
||||||
|
} else {
|
||||||
|
update = Update::Any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options.
|
||||||
|
Long("message") | Short('m') => {
|
||||||
|
let txt: String = parser.value()?.to_string_lossy().into();
|
||||||
|
message.append(&txt);
|
||||||
|
}
|
||||||
|
Long("no-message") => {
|
||||||
|
message = Comment::Blank;
|
||||||
|
}
|
||||||
|
Long("sync") => {
|
||||||
|
sync = true;
|
||||||
|
}
|
||||||
|
Long("no-sync") => {
|
||||||
|
sync = false;
|
||||||
|
}
|
||||||
|
Long("push") => {
|
||||||
|
push = true;
|
||||||
|
}
|
||||||
|
Long("no-push") => {
|
||||||
|
push = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common.
|
||||||
|
Long("verbose") | Short('v') => {
|
||||||
|
verbose = true;
|
||||||
|
}
|
||||||
|
Long("help") => {
|
||||||
|
return Err(Error::Help.into());
|
||||||
|
}
|
||||||
|
_ => return Err(anyhow::anyhow!(arg.unexpected())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
Options {
|
||||||
|
list,
|
||||||
|
sync,
|
||||||
|
message,
|
||||||
|
push,
|
||||||
|
update,
|
||||||
|
verbose,
|
||||||
|
},
|
||||||
|
vec![],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
|
let (workdir, id) = radicle::rad::cwd()
|
||||||
|
.map_err(|_| anyhow!("this command must be run in the context of a project"))?;
|
||||||
|
|
||||||
|
let profile = ctx.profile()?;
|
||||||
|
let storage = profile.storage.repository(id)?;
|
||||||
|
|
||||||
|
if options.list {
|
||||||
|
list::run(&storage, &profile, Some(workdir), options)?;
|
||||||
|
} else {
|
||||||
|
create::run(&storage, &profile, &workdir, options)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
use radicle::cob::patch::{MergeTarget, Patch, PatchId, PatchStore};
|
||||||
|
use radicle::git;
|
||||||
|
use radicle::git::raw::Oid;
|
||||||
|
use radicle::prelude::*;
|
||||||
|
use radicle::storage::git::Repository;
|
||||||
|
use radicle::storage::Remote;
|
||||||
|
|
||||||
|
use crate::terminal as term;
|
||||||
|
use crate::terminal::args::Error;
|
||||||
|
|
||||||
|
/// List of merge targets.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct MergeTargets {
|
||||||
|
/// Merge targets that have already merged the patch.
|
||||||
|
pub merged: Vec<Remote>,
|
||||||
|
/// Merge targets that haven't merged the patch.
|
||||||
|
pub not_merged: Vec<(Remote, git::Oid)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find potential merge targets for the given head.
|
||||||
|
pub fn find_merge_targets(
|
||||||
|
head: &Oid,
|
||||||
|
branch: &git::RefStr,
|
||||||
|
storage: &Repository,
|
||||||
|
) -> anyhow::Result<MergeTargets> {
|
||||||
|
let mut targets = MergeTargets::default();
|
||||||
|
|
||||||
|
for remote in storage.remotes()? {
|
||||||
|
let (_, remote) = remote?;
|
||||||
|
let Some(target_oid) = remote.refs.head(branch) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_merged(storage.raw(), target_oid.into(), *head)? {
|
||||||
|
targets.merged.push(remote);
|
||||||
|
} else {
|
||||||
|
targets.not_merged.push((remote, target_oid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the [`Oid`] of the merge target.
|
||||||
|
pub fn patch_merge_target_oid(target: MergeTarget, repository: &Repository) -> anyhow::Result<Oid> {
|
||||||
|
match target {
|
||||||
|
MergeTarget::Delegates => {
|
||||||
|
if let Ok((_, target)) = repository.head() {
|
||||||
|
Ok(*target)
|
||||||
|
} else {
|
||||||
|
anyhow::bail!(
|
||||||
|
"failed to determine default branch head for project {}",
|
||||||
|
repository.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a human friendly message about git's sync status.
|
||||||
|
pub fn pretty_sync_status(
|
||||||
|
repo: &git::raw::Repository,
|
||||||
|
revision_oid: Oid,
|
||||||
|
head_oid: Oid,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let (a, b) = repo.graph_ahead_behind(revision_oid, head_oid)?;
|
||||||
|
if a == 0 && b == 0 {
|
||||||
|
return Ok(term::format::dim("up to date"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ahead = term::format::positive(a);
|
||||||
|
let behind = term::format::negative(b);
|
||||||
|
|
||||||
|
Ok(format!("ahead {}, behind {}", ahead, behind))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a human friendly string for commit version information.
|
||||||
|
///
|
||||||
|
/// For example '<oid> (branch1[, branch2])'.
|
||||||
|
pub fn pretty_commit_version(
|
||||||
|
revision_oid: &Oid,
|
||||||
|
repo: &Option<git::raw::Repository>,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let mut oid = term::format::secondary(term::format::oid(*revision_oid));
|
||||||
|
let mut branches: Vec<String> = vec![];
|
||||||
|
|
||||||
|
if let Some(repo) = repo {
|
||||||
|
for r in repo.references()?.flatten() {
|
||||||
|
if !r.is_branch() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let (Some(oid), Some(name)) = (&r.target(), &r.shorthand()) {
|
||||||
|
if oid == revision_oid {
|
||||||
|
branches.push(name.to_string());
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !branches.is_empty() {
|
||||||
|
oid = format!(
|
||||||
|
"{} {}",
|
||||||
|
oid,
|
||||||
|
term::format::yellow(format!("({})", branches.join(", "))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(oid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find patches with a merge base equal to the one provided.
|
||||||
|
pub fn find_unmerged_with_base(
|
||||||
|
patch_head: Oid,
|
||||||
|
target_head: Oid,
|
||||||
|
merge_base: Oid,
|
||||||
|
patches: &PatchStore,
|
||||||
|
workdir: &git::raw::Repository,
|
||||||
|
) -> anyhow::Result<Vec<(PatchId, Patch)>> {
|
||||||
|
// My patches.
|
||||||
|
let proposed: Vec<_> = patches.proposed_by(patches.public_key())?.collect();
|
||||||
|
let mut matches = Vec::new();
|
||||||
|
|
||||||
|
for (id, patch) in proposed {
|
||||||
|
let (_, rev) = patch.latest();
|
||||||
|
|
||||||
|
if !rev.merges.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if **patch.head() == patch_head {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Merge-base between the two patches.
|
||||||
|
if workdir.merge_base(**patch.head(), target_head)? == merge_base {
|
||||||
|
matches.push((id, patch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return commits between the merge base and a head.
|
||||||
|
pub fn patch_commits<'a>(
|
||||||
|
repo: &'a git::raw::Repository,
|
||||||
|
base: &Oid,
|
||||||
|
head: &Oid,
|
||||||
|
) -> anyhow::Result<Vec<git::raw::Commit<'a>>> {
|
||||||
|
let mut commits = Vec::new();
|
||||||
|
let mut revwalk = repo.revwalk()?;
|
||||||
|
revwalk.push_range(&format!("{}..{}", base, head))?;
|
||||||
|
|
||||||
|
for rev in revwalk {
|
||||||
|
let commit = repo.find_commit(rev?)?;
|
||||||
|
commits.push(commit);
|
||||||
|
}
|
||||||
|
Ok(commits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a commit has been merged into a target branch.
|
||||||
|
pub fn is_merged(repo: &git::raw::Repository, target: Oid, commit: Oid) -> Result<bool, Error> {
|
||||||
|
if let Ok(base) = repo.merge_base(target, commit) {
|
||||||
|
Ok(base == commit)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
|
use radicle::cob::patch::{MergeTarget, Patch, PatchId, PatchStore};
|
||||||
|
use radicle::git;
|
||||||
|
use radicle::git::raw::Oid;
|
||||||
|
use radicle::prelude::*;
|
||||||
|
use radicle::storage::git::Repository;
|
||||||
|
|
||||||
|
use crate::terminal as term;
|
||||||
|
use crate::terminal::args::Error;
|
||||||
|
|
||||||
|
use super::common;
|
||||||
|
use super::{Options, Update};
|
||||||
|
|
||||||
|
const PATCH_MSG: &str = r#"
|
||||||
|
<!--
|
||||||
|
Please enter a patch message for your changes. An empty
|
||||||
|
message aborts the patch proposal.
|
||||||
|
|
||||||
|
The first line is the patch title. The patch description
|
||||||
|
follows, and must be separated with a blank line, just
|
||||||
|
like a commit message. Markdown is supported in the title
|
||||||
|
and description.
|
||||||
|
-->
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const REVISION_MSG: &str = r#"
|
||||||
|
<!--
|
||||||
|
Please enter a comment message for your patch update. Leaving this
|
||||||
|
blank is also okay.
|
||||||
|
-->
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Run patch creation.
|
||||||
|
pub fn run(
|
||||||
|
storage: &Repository,
|
||||||
|
profile: &Profile,
|
||||||
|
workdir: &git::raw::Repository,
|
||||||
|
options: Options,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let project = storage.project_of(&profile.public_key).context(format!(
|
||||||
|
"couldn't load project {} from local state",
|
||||||
|
storage.id
|
||||||
|
))?;
|
||||||
|
|
||||||
|
term::headline(&format!(
|
||||||
|
"🌱 Creating patch for {}",
|
||||||
|
term::format::highlight(&project.name)
|
||||||
|
));
|
||||||
|
|
||||||
|
let signer = term::signer(profile)?;
|
||||||
|
let cobs = radicle::cob::Store::open(profile.public_key, storage)?;
|
||||||
|
let patches = cobs.patches();
|
||||||
|
|
||||||
|
// `HEAD`; This is what we are proposing as a patch.
|
||||||
|
let head = workdir.head()?;
|
||||||
|
let head_oid = head.target().ok_or(anyhow!("invalid HEAD ref; aborting"))?;
|
||||||
|
let head_commit = workdir.find_commit(head_oid)?;
|
||||||
|
let head_branch = head
|
||||||
|
.shorthand()
|
||||||
|
.ok_or(anyhow!("cannot create patch from detached head; aborting"))?;
|
||||||
|
|
||||||
|
// Make sure the `HEAD` commit can be found in the monorepo. Otherwise there
|
||||||
|
// is no way for anyone to merge this patch.
|
||||||
|
let mut spinner = term::spinner(format!(
|
||||||
|
"Looking for HEAD ({}) in storage...",
|
||||||
|
term::format::secondary(term::format::oid(head_oid))
|
||||||
|
));
|
||||||
|
if storage.commit(head_oid.into()).is_err() {
|
||||||
|
if !options.push {
|
||||||
|
spinner.failed();
|
||||||
|
term::blank();
|
||||||
|
|
||||||
|
return Err(Error::WithHint {
|
||||||
|
err: anyhow!("Current branch head was not found in storage"),
|
||||||
|
hint: "hint: run `git push rad` and try again",
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
spinner.message("Pushing HEAD to storage...");
|
||||||
|
|
||||||
|
let output = git::run::<_, _, &str, &str>(Path::new("."), ["push", "rad"], [])?;
|
||||||
|
if options.verbose {
|
||||||
|
spinner.finish();
|
||||||
|
term::blob(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spinner.finish();
|
||||||
|
|
||||||
|
// Determine the merge target for this patch. This can ben any tracked remote's "default"
|
||||||
|
// branch, as well as your own (eg. `rad/master`).
|
||||||
|
let mut spinner = term::spinner("Analyzing remotes...");
|
||||||
|
let targets =
|
||||||
|
common::find_merge_targets(&head_oid, project.default_branch.as_refstr(), storage)?;
|
||||||
|
|
||||||
|
// eg. `refs/namespaces/<peer>/refs/heads/master`
|
||||||
|
let (target_peer, target_oid) = match targets.not_merged.as_slice() {
|
||||||
|
[] => {
|
||||||
|
spinner.message("All tracked peers are up to date.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
[target] => target,
|
||||||
|
_ => {
|
||||||
|
// TODO: Let user select which branch to use as a target.
|
||||||
|
todo!();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// TODO: Tell user how many peers don't have this change.
|
||||||
|
spinner.finish();
|
||||||
|
|
||||||
|
// TODO: Handle case where `rad/master` isn't up to date with the target.
|
||||||
|
// In that case we should warn the user that their master branch is not up
|
||||||
|
// to date, and error out, unless the user specifies manually the merge
|
||||||
|
// base.
|
||||||
|
|
||||||
|
// The merge base is basically the commit at which the histories diverge.
|
||||||
|
let base_oid = workdir.merge_base((*target_oid).into(), head_oid)?;
|
||||||
|
let commits = common::patch_commits(workdir, &base_oid, &head_oid)?;
|
||||||
|
|
||||||
|
let patch = match &options.update {
|
||||||
|
Update::No => None,
|
||||||
|
Update::Any => {
|
||||||
|
let mut spinner = term::spinner("Finding patches to update...");
|
||||||
|
let mut result = common::find_unmerged_with_base(
|
||||||
|
head_oid,
|
||||||
|
**target_oid,
|
||||||
|
base_oid,
|
||||||
|
&patches,
|
||||||
|
workdir,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if let Some((id, patch)) = result.pop() {
|
||||||
|
if result.is_empty() {
|
||||||
|
spinner.message(format!(
|
||||||
|
"Found existing patch {} {}",
|
||||||
|
term::format::tertiary(term::format::cob(&id)),
|
||||||
|
term::format::italic(&patch.title)
|
||||||
|
));
|
||||||
|
spinner.finish();
|
||||||
|
term::blank();
|
||||||
|
|
||||||
|
Some((id, patch))
|
||||||
|
} else {
|
||||||
|
spinner.failed();
|
||||||
|
term::blank();
|
||||||
|
anyhow::bail!("More than one patch available to update, please specify an id with `rad patch --update <id>`");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
spinner.failed();
|
||||||
|
term::blank();
|
||||||
|
anyhow::bail!("No patches found that share a base, please create a new patch or specify the patch id manually");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Update::Patch(id) => {
|
||||||
|
if let Some(patch) = patches.get(id)? {
|
||||||
|
Some((*id, patch))
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("Patch `{}` not found", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((id, patch)) = patch {
|
||||||
|
if term::confirm("Update?") {
|
||||||
|
term::blank();
|
||||||
|
|
||||||
|
return update(
|
||||||
|
patch, id, &base_oid, &head_oid, &patches, workdir, options, &signer,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("Patch update aborted by user");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: List matching working copy refs for all targets.
|
||||||
|
|
||||||
|
term::blank();
|
||||||
|
term::info!(
|
||||||
|
"{}/{} ({}) <- {}/{} ({})",
|
||||||
|
term::format::dim(target_peer.id),
|
||||||
|
term::format::highlight(&project.default_branch.to_string()),
|
||||||
|
term::format::secondary(&term::format::oid(*target_oid)),
|
||||||
|
term::format::dim(term::format::node(cobs.public_key())),
|
||||||
|
term::format::highlight(&head_branch.to_string()),
|
||||||
|
term::format::secondary(&term::format::oid(head_oid)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Test case where the target branch has been re-written passed the merge-base, since the fork was created
|
||||||
|
// This can also happen *after* the patch is created.
|
||||||
|
|
||||||
|
term::patch::print_commits_ahead_behind(workdir, head_oid, (*target_oid).into())?;
|
||||||
|
|
||||||
|
// List commits in patch that aren't in the target branch.
|
||||||
|
term::blank();
|
||||||
|
term::patch::list_commits(&commits)?;
|
||||||
|
term::blank();
|
||||||
|
|
||||||
|
if !term::confirm("Continue?") {
|
||||||
|
anyhow::bail!("patch proposal aborted by user");
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = head_commit
|
||||||
|
.message()
|
||||||
|
.ok_or(anyhow!("commit summary is not valid UTF-8; aborting"))?;
|
||||||
|
let message = options.message.get(&format!("{}{}", message, PATCH_MSG));
|
||||||
|
let (title, description) = message.split_once("\n\n").unwrap_or((&message, ""));
|
||||||
|
let (title, description) = (title.trim(), description.trim());
|
||||||
|
let description = description.replace(PATCH_MSG.trim(), ""); // Delete help message.
|
||||||
|
|
||||||
|
if title.is_empty() {
|
||||||
|
anyhow::bail!("a title must be given");
|
||||||
|
}
|
||||||
|
|
||||||
|
let title_pretty = &term::format::dim(format!("╭─ {} ───────", title));
|
||||||
|
|
||||||
|
term::blank();
|
||||||
|
term::print(title_pretty);
|
||||||
|
term::blank();
|
||||||
|
|
||||||
|
if description.is_empty() {
|
||||||
|
term::print(term::format::italic("No description provided."));
|
||||||
|
} else {
|
||||||
|
term::markdown(&description);
|
||||||
|
}
|
||||||
|
|
||||||
|
term::blank();
|
||||||
|
term::print(&term::format::dim(format!(
|
||||||
|
"╰{}",
|
||||||
|
"─".repeat(term::text_width(title_pretty) - 1)
|
||||||
|
)));
|
||||||
|
term::blank();
|
||||||
|
|
||||||
|
if !term::confirm("Create patch?") {
|
||||||
|
anyhow::bail!("patch proposal aborted by user");
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = patches.create(
|
||||||
|
title,
|
||||||
|
&description,
|
||||||
|
MergeTarget::default(),
|
||||||
|
base_oid,
|
||||||
|
head_oid,
|
||||||
|
&[],
|
||||||
|
&signer,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
term::blank();
|
||||||
|
term::success!("Patch {} created 🌱", term::format::highlight(id));
|
||||||
|
|
||||||
|
if options.sync {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing patch with a new revision.
|
||||||
|
fn update<G: Signer>(
|
||||||
|
patch: Patch,
|
||||||
|
patch_id: PatchId,
|
||||||
|
base: &Oid,
|
||||||
|
head: &Oid,
|
||||||
|
patches: &PatchStore,
|
||||||
|
workdir: &git::raw::Repository,
|
||||||
|
options: Options,
|
||||||
|
signer: &G,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let (current, current_revision) = patch.latest();
|
||||||
|
|
||||||
|
if *current_revision.oid == *head {
|
||||||
|
term::info!("Nothing to do, patch is already up to date.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
term::info!(
|
||||||
|
"{} {} ({}) -> {} ({})",
|
||||||
|
term::format::tertiary(term::format::cob(&patch_id)),
|
||||||
|
term::format::dim(format!("R{}", current)),
|
||||||
|
term::format::secondary(term::format::oid(current_revision.oid)),
|
||||||
|
term::format::dim(format!("R{}", current + 1)),
|
||||||
|
term::format::secondary(term::format::oid(*head)),
|
||||||
|
);
|
||||||
|
let message = options.message.get(REVISION_MSG);
|
||||||
|
|
||||||
|
// Difference between the two revisions.
|
||||||
|
term::patch::print_commits_ahead_behind(workdir, *head, *current_revision.oid)?;
|
||||||
|
term::blank();
|
||||||
|
|
||||||
|
if !term::confirm("Continue?") {
|
||||||
|
anyhow::bail!("patch update aborted by user");
|
||||||
|
}
|
||||||
|
|
||||||
|
let new = patches.update(&patch_id, message, *base, *head, signer)?;
|
||||||
|
assert_eq!(new, current + 1);
|
||||||
|
|
||||||
|
term::blank();
|
||||||
|
term::success!("Patch {} updated 🌱", term::format::highlight(patch_id));
|
||||||
|
term::blank();
|
||||||
|
|
||||||
|
if options.sync {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
use radicle::cob;
|
||||||
|
use radicle::cob::patch::{Patch, PatchId, Verdict};
|
||||||
|
use radicle::git;
|
||||||
|
use radicle::prelude::*;
|
||||||
|
use radicle::profile::Profile;
|
||||||
|
use radicle::storage::git::Repository;
|
||||||
|
|
||||||
|
use crate::terminal as term;
|
||||||
|
|
||||||
|
use super::common;
|
||||||
|
use super::Options;
|
||||||
|
|
||||||
|
/// List patches.
|
||||||
|
pub fn run(
|
||||||
|
storage: &Repository,
|
||||||
|
profile: &Profile,
|
||||||
|
workdir: Option<git::raw::Repository>,
|
||||||
|
options: Options,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if options.sync {
|
||||||
|
// TODO: Sync
|
||||||
|
}
|
||||||
|
|
||||||
|
let me = *profile.id();
|
||||||
|
let cobs = cob::Store::open(*profile.id(), storage)?;
|
||||||
|
let patches = cobs.patches();
|
||||||
|
let proposed = patches.proposed()?;
|
||||||
|
|
||||||
|
// Patches the user authored.
|
||||||
|
let mut own = Vec::new();
|
||||||
|
// Patches other users authored.
|
||||||
|
let mut other = Vec::new();
|
||||||
|
|
||||||
|
for (id, patch) in proposed {
|
||||||
|
if *patch.author.id() == me {
|
||||||
|
own.push((id, patch));
|
||||||
|
} else {
|
||||||
|
other.push((id, patch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
term::blank();
|
||||||
|
term::print(&term::format::badge_positive("YOU PROPOSED"));
|
||||||
|
|
||||||
|
if own.is_empty() {
|
||||||
|
term::blank();
|
||||||
|
term::print(&term::format::italic("Nothing to show."));
|
||||||
|
} else {
|
||||||
|
for (id, patch) in &mut own {
|
||||||
|
term::blank();
|
||||||
|
|
||||||
|
print(&me, id, patch, &workdir, storage)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
term::blank();
|
||||||
|
term::print(&term::format::badge_secondary("OTHERS PROPOSED"));
|
||||||
|
|
||||||
|
if other.is_empty() {
|
||||||
|
term::blank();
|
||||||
|
term::print(&term::format::italic("Nothing to show."));
|
||||||
|
} else {
|
||||||
|
for (id, patch) in &mut other {
|
||||||
|
term::blank();
|
||||||
|
|
||||||
|
print(cobs.public_key(), id, patch, &workdir, storage)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
term::blank();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print patch details.
|
||||||
|
fn print(
|
||||||
|
whoami: &PublicKey,
|
||||||
|
patch_id: &PatchId,
|
||||||
|
patch: &Patch,
|
||||||
|
workdir: &Option<git::raw::Repository>,
|
||||||
|
storage: &Repository,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let target_head = common::patch_merge_target_oid(patch.target, storage)?;
|
||||||
|
|
||||||
|
let you = patch.author.id() == whoami;
|
||||||
|
let prefix = "└─ ";
|
||||||
|
let mut author_info = vec![format!(
|
||||||
|
"{}* opened by {}",
|
||||||
|
prefix,
|
||||||
|
term::format::tertiary(patch.author.id()),
|
||||||
|
)];
|
||||||
|
|
||||||
|
if you {
|
||||||
|
author_info.push(term::format::secondary("(you)"));
|
||||||
|
}
|
||||||
|
author_info.push(term::format::dim(term::format::timestamp(&patch.timestamp)));
|
||||||
|
|
||||||
|
let revision = patch.revisions.last();
|
||||||
|
term::info!(
|
||||||
|
"{} {} {} {} {}",
|
||||||
|
term::format::bold(&patch.title),
|
||||||
|
term::format::highlight(term::format::cob(patch_id)),
|
||||||
|
term::format::dim(format!("R{}", patch.version())),
|
||||||
|
common::pretty_commit_version(&revision.oid, workdir)?,
|
||||||
|
common::pretty_sync_status(storage.raw(), *revision.oid, target_head)?,
|
||||||
|
);
|
||||||
|
term::info!("{}", author_info.join(" "));
|
||||||
|
|
||||||
|
let mut timeline = Vec::new();
|
||||||
|
for merge in &revision.merges {
|
||||||
|
let peer = storage.remote(&merge.node)?;
|
||||||
|
let mut badges = Vec::new();
|
||||||
|
|
||||||
|
if peer.delegate {
|
||||||
|
badges.push(term::format::secondary("(delegate)"));
|
||||||
|
}
|
||||||
|
if peer.id == *whoami {
|
||||||
|
badges.push(term::format::secondary("(you)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
timeline.push((
|
||||||
|
merge.timestamp,
|
||||||
|
format!(
|
||||||
|
"{}{} by {} {}",
|
||||||
|
" ".repeat(term::text_width(prefix)),
|
||||||
|
term::format::secondary(term::format::dim("✓ merged")),
|
||||||
|
term::format::tertiary(peer.id),
|
||||||
|
badges.join(" "),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for review in revision.reviews.values() {
|
||||||
|
let verdict = match review.verdict {
|
||||||
|
Some(Verdict::Accept) => term::format::positive(term::format::dim("✓ accepted")),
|
||||||
|
Some(Verdict::Reject) => term::format::negative(term::format::dim("✗ rejected")),
|
||||||
|
None => term::format::negative(term::format::dim("⋄ reviewed")),
|
||||||
|
};
|
||||||
|
let peer = storage.remote(review.author.id())?;
|
||||||
|
let mut badges = Vec::new();
|
||||||
|
|
||||||
|
if peer.delegate {
|
||||||
|
badges.push(term::format::secondary("(delegate)"));
|
||||||
|
}
|
||||||
|
if peer.id == *whoami {
|
||||||
|
badges.push(term::format::secondary("(you)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
timeline.push((
|
||||||
|
review.timestamp,
|
||||||
|
format!(
|
||||||
|
"{}{} by {} {}",
|
||||||
|
" ".repeat(term::text_width(prefix)),
|
||||||
|
verdict,
|
||||||
|
term::format::tertiary(review.author.id()),
|
||||||
|
badges.join(" "),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
timeline.sort_by_key(|(t, _)| *t);
|
||||||
|
|
||||||
|
for (time, event) in timeline.iter().rev() {
|
||||||
|
term::info!(
|
||||||
|
"{} {}",
|
||||||
|
event,
|
||||||
|
term::format::dim(term::format::timestamp(time))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
#![allow(clippy::collapsible_if)]
|
#![allow(clippy::collapsible_if)]
|
||||||
|
#![allow(clippy::or_fun_call)]
|
||||||
|
#![allow(clippy::too_many_arguments)]
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use std::fmt;
|
use std::{fmt, time};
|
||||||
|
|
||||||
use dialoguer::console::style;
|
use dialoguer::console::style;
|
||||||
|
use radicle::cob::Timestamp;
|
||||||
use radicle::node::NodeId;
|
use radicle::node::NodeId;
|
||||||
use radicle::profile::Profile;
|
use radicle::profile::Profile;
|
||||||
use radicle_cob::ObjectId;
|
use radicle_cob::ObjectId;
|
||||||
|
|
@ -17,8 +18,8 @@ pub fn node(node: &NodeId) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a git Oid.
|
/// Format a git Oid.
|
||||||
pub fn oid(oid: &radicle::git::Oid) -> String {
|
pub fn oid(oid: impl Into<radicle::git::Oid>) -> String {
|
||||||
format!("{:.7}", oid)
|
format!("{:.7}", oid.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a COB id.
|
/// Format a COB id.
|
||||||
|
|
@ -26,6 +27,15 @@ pub fn cob(id: &ObjectId) -> String {
|
||||||
format!("{:.11}", id.to_string())
|
format!("{:.11}", id.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format a timestamp.
|
||||||
|
pub fn timestamp(time: &Timestamp) -> String {
|
||||||
|
let fmt = timeago::Formatter::new();
|
||||||
|
let now = Timestamp::now();
|
||||||
|
let duration = time::Duration::from_secs(now.as_secs() - time.as_secs());
|
||||||
|
|
||||||
|
fmt.convert(duration)
|
||||||
|
}
|
||||||
|
|
||||||
/// Identity formatter that takes a profile and displays it as
|
/// Identity formatter that takes a profile and displays it as
|
||||||
/// `<node-id> (<username>)` depending on the configuration.
|
/// `<node-id> (<username>)` depending on the configuration.
|
||||||
pub struct Identity<'a> {
|
pub struct Identity<'a> {
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
|
||||||
.summary_bytes()
|
.summary_bytes()
|
||||||
.unwrap_or_else(|| commit.message_bytes());
|
.unwrap_or_else(|| commit.message_bytes());
|
||||||
table.push([
|
table.push([
|
||||||
term::format::secondary(term::format::oid(&commit.id().into())),
|
term::format::secondary(term::format::oid(commit.id())),
|
||||||
term::format::italic(String::from_utf8_lossy(message)),
|
term::format::italic(String::from_utf8_lossy(message)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,15 @@ pub mod transaction;
|
||||||
pub mod value;
|
pub mod value;
|
||||||
|
|
||||||
pub use cob::{
|
pub use cob::{
|
||||||
identity, object::collaboration::error, CollaborativeObject, Create, Entry, History, ObjectId,
|
identity, object::collaboration::error, CollaborativeObject, Contents, Create, Entry, History,
|
||||||
TypeName, Update,
|
ObjectId, TypeName, Update,
|
||||||
};
|
};
|
||||||
|
pub use shared::Timestamp;
|
||||||
|
pub use store::Store;
|
||||||
|
|
||||||
use radicle_cob as cob;
|
use radicle_cob as cob;
|
||||||
use radicle_git_ext::Oid;
|
use radicle_git_ext::Oid;
|
||||||
|
|
||||||
pub use radicle_cob::*;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
identity::{project::Identity, Did},
|
identity::{project::Identity, Did},
|
||||||
node::NodeId,
|
node::NodeId,
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,11 @@ impl<'a, T> Store<'a, T> {
|
||||||
pub fn author(&self) -> Author {
|
pub fn author(&self) -> Author {
|
||||||
Author::new(self.whoami)
|
Author::new(self.whoami)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the public key associated with this store.
|
||||||
|
pub fn public_key(&self) -> &PublicKey {
|
||||||
|
&self.whoami
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, T: FromHistory> Store<'a, T> {
|
impl<'a, T: FromHistory> Store<'a, T> {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use crate::storage::refs::SignedRefs;
|
||||||
use crate::storage::{BranchName, ReadRepository as _, RemoteId, WriteRepository as _};
|
use crate::storage::{BranchName, ReadRepository as _, RemoteId, WriteRepository as _};
|
||||||
use crate::{identity, storage};
|
use crate::{identity, storage};
|
||||||
|
|
||||||
|
/// Name of the radicle storage remote.
|
||||||
pub static REMOTE_NAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("rad"));
|
pub static REMOTE_NAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("rad"));
|
||||||
|
|
||||||
/// Radicle remote name for peer, eg. `rad/<node-id>`
|
/// Radicle remote name for peer, eg. `rad/<node-id>`
|
||||||
|
|
@ -346,14 +347,15 @@ mod tests {
|
||||||
|
|
||||||
use radicle_crypto::test::signer::MockSigner;
|
use radicle_crypto::test::signer::MockSigner;
|
||||||
|
|
||||||
use super::*;
|
use crate::git::{name::component, qualified, Qualified};
|
||||||
use crate::git::fmt::refname;
|
|
||||||
use crate::identity::{Delegate, Did};
|
use crate::identity::{Delegate, Did};
|
||||||
use crate::storage::git::transport;
|
use crate::storage::git::transport;
|
||||||
use crate::storage::git::Storage;
|
use crate::storage::git::Storage;
|
||||||
use crate::storage::{ReadStorage, WriteStorage};
|
use crate::storage::{ReadStorage, WriteStorage};
|
||||||
use crate::test::fixtures;
|
use crate::test::fixtures;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_init() {
|
fn test_init() {
|
||||||
let tempdir = tempfile::tempdir().unwrap();
|
let tempdir = tempfile::tempdir().unwrap();
|
||||||
|
|
@ -387,6 +389,7 @@ mod tests {
|
||||||
let (_, head) = project_repo.head().unwrap();
|
let (_, head) = project_repo.head().unwrap();
|
||||||
|
|
||||||
// Test canonical refs.
|
// Test canonical refs.
|
||||||
|
assert_eq!(refs.head(&component!("master")).unwrap(), head);
|
||||||
assert_eq!(project_repo.raw().refname_to_id("HEAD").unwrap(), *head);
|
assert_eq!(project_repo.raw().refname_to_id("HEAD").unwrap(), *head);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
project_repo
|
project_repo
|
||||||
|
|
@ -439,8 +442,11 @@ mod tests {
|
||||||
let bob_remote = storage.repository(id).unwrap().remote(bob_id).unwrap();
|
let bob_remote = storage.repository(id).unwrap().remote(bob_id).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bob_remote.refs.get(&refname!("master")),
|
bob_remote
|
||||||
alice_refs.get(&refname!("master"))
|
.refs
|
||||||
|
.get(&qualified!("refs/heads/master"))
|
||||||
|
.unwrap(),
|
||||||
|
alice_refs.get(&qualified!("refs/heads/master")).unwrap()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ impl<V> From<Remotes<V>> for HashMap<RemoteId, Refs> {
|
||||||
|
|
||||||
/// A project remote.
|
/// A project remote.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Remote<V> {
|
pub struct Remote<V = Verified> {
|
||||||
/// ID of remote.
|
/// ID of remote.
|
||||||
pub id: PublicKey,
|
pub id: PublicKey,
|
||||||
/// Git references published under this remote, and their hashes.
|
/// Git references published under this remote, and their hashes.
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,17 @@ impl Refs {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a particular ref.
|
||||||
|
pub fn get(&self, name: &git::Qualified) -> Option<Oid> {
|
||||||
|
self.0.get(name.to_ref_string().as_refstr()).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a particular head ref.
|
||||||
|
pub fn head(&self, name: impl AsRef<git::RefStr>) -> Option<Oid> {
|
||||||
|
let branch = git::refname!("refs/heads").join(name);
|
||||||
|
self.0.get(&branch).copied()
|
||||||
|
}
|
||||||
|
|
||||||
/// Create refs from a canonical representation.
|
/// Create refs from a canonical representation.
|
||||||
pub fn from_canonical(bytes: &[u8]) -> Result<Self, canonical::Error> {
|
pub fn from_canonical(bytes: &[u8]) -> Result<Self, canonical::Error> {
|
||||||
let reader = BufReader::new(bytes);
|
let reader = BufReader::new(bytes);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue