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",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"timeago",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
|
@ -2242,6 +2243,12 @@ dependencies = [
|
|||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "timeago"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ec32dde57efb15c035ac074118d7f32820451395f28cb0524a01d4e94983b26"
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.6.0"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ lexopt = { version = "0.2" }
|
|||
log = { version = "0.4", features = ["std"] }
|
||||
serde_json = { version = "1" }
|
||||
thiserror = { version = "1" }
|
||||
timeago = { version = "0.3", default-features = false }
|
||||
zeroize = { version = "1.1" }
|
||||
|
||||
[dependencies.radicle]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ pub mod rad_init;
|
|||
pub mod rad_inspect;
|
||||
#[path = "commands/ls.rs"]
|
||||
pub mod rad_ls;
|
||||
#[path = "commands/patch.rs"]
|
||||
pub mod rad_patch;
|
||||
#[path = "commands/push.rs"]
|
||||
pub mod rad_push;
|
||||
#[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((_, head)) = repo.head() 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([
|
||||
term::format::bold(payload.name),
|
||||
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::or_fun_call)]
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
pub mod commands;
|
||||
pub mod git;
|
||||
pub mod project;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::fmt;
|
||||
use std::{fmt, time};
|
||||
|
||||
use dialoguer::console::style;
|
||||
use radicle::cob::Timestamp;
|
||||
use radicle::node::NodeId;
|
||||
use radicle::profile::Profile;
|
||||
use radicle_cob::ObjectId;
|
||||
|
|
@ -17,8 +18,8 @@ pub fn node(node: &NodeId) -> String {
|
|||
}
|
||||
|
||||
/// Format a git Oid.
|
||||
pub fn oid(oid: &radicle::git::Oid) -> String {
|
||||
format!("{:.7}", oid)
|
||||
pub fn oid(oid: impl Into<radicle::git::Oid>) -> String {
|
||||
format!("{:.7}", oid.into())
|
||||
}
|
||||
|
||||
/// Format a COB id.
|
||||
|
|
@ -26,6 +27,15 @@ pub fn cob(id: &ObjectId) -> 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
|
||||
/// `<node-id> (<username>)` depending on the configuration.
|
||||
pub struct Identity<'a> {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
|
|||
.summary_bytes()
|
||||
.unwrap_or_else(|| commit.message_bytes());
|
||||
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)),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,15 @@ pub mod transaction;
|
|||
pub mod value;
|
||||
|
||||
pub use cob::{
|
||||
identity, object::collaboration::error, CollaborativeObject, Create, Entry, History, ObjectId,
|
||||
TypeName, Update,
|
||||
identity, object::collaboration::error, CollaborativeObject, Contents, Create, Entry, History,
|
||||
ObjectId, TypeName, Update,
|
||||
};
|
||||
pub use shared::Timestamp;
|
||||
pub use store::Store;
|
||||
|
||||
use radicle_cob as cob;
|
||||
use radicle_git_ext::Oid;
|
||||
|
||||
pub use radicle_cob::*;
|
||||
|
||||
use crate::{
|
||||
identity::{project::Identity, Did},
|
||||
node::NodeId,
|
||||
|
|
|
|||
|
|
@ -111,6 +111,11 @@ impl<'a, T> Store<'a, T> {
|
|||
pub fn author(&self) -> Author {
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use crate::storage::refs::SignedRefs;
|
|||
use crate::storage::{BranchName, ReadRepository as _, RemoteId, WriteRepository as _};
|
||||
use crate::{identity, storage};
|
||||
|
||||
/// Name of the radicle storage remote.
|
||||
pub static REMOTE_NAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("rad"));
|
||||
|
||||
/// Radicle remote name for peer, eg. `rad/<node-id>`
|
||||
|
|
@ -346,14 +347,15 @@ mod tests {
|
|||
|
||||
use radicle_crypto::test::signer::MockSigner;
|
||||
|
||||
use super::*;
|
||||
use crate::git::fmt::refname;
|
||||
use crate::git::{name::component, qualified, Qualified};
|
||||
use crate::identity::{Delegate, Did};
|
||||
use crate::storage::git::transport;
|
||||
use crate::storage::git::Storage;
|
||||
use crate::storage::{ReadStorage, WriteStorage};
|
||||
use crate::test::fixtures;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_init() {
|
||||
let tempdir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -387,6 +389,7 @@ mod tests {
|
|||
let (_, head) = project_repo.head().unwrap();
|
||||
|
||||
// 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
|
||||
|
|
@ -439,8 +442,11 @@ mod tests {
|
|||
let bob_remote = storage.repository(id).unwrap().remote(bob_id).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
bob_remote.refs.get(&refname!("master")),
|
||||
alice_refs.get(&refname!("master"))
|
||||
bob_remote
|
||||
.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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Remote<V> {
|
||||
pub struct Remote<V = Verified> {
|
||||
/// ID of remote.
|
||||
pub id: PublicKey,
|
||||
/// 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.
|
||||
pub fn from_canonical(bytes: &[u8]) -> Result<Self, canonical::Error> {
|
||||
let reader = BufReader::new(bytes);
|
||||
|
|
|
|||
Loading…
Reference in New Issue