578 lines
19 KiB
Rust
578 lines
19 KiB
Rust
#[path = "patch/archive.rs"]
|
|
mod archive;
|
|
#[path = "patch/checkout.rs"]
|
|
mod checkout;
|
|
#[path = "patch/comment.rs"]
|
|
mod comment;
|
|
#[path = "patch/common.rs"]
|
|
mod common;
|
|
#[path = "patch/delete.rs"]
|
|
mod delete;
|
|
#[path = "patch/edit.rs"]
|
|
mod edit;
|
|
#[path = "patch/label.rs"]
|
|
mod label;
|
|
#[path = "patch/list.rs"]
|
|
mod list;
|
|
#[path = "patch/ready.rs"]
|
|
mod ready;
|
|
#[path = "patch/redact.rs"]
|
|
mod redact;
|
|
#[path = "patch/show.rs"]
|
|
mod show;
|
|
#[path = "patch/update.rs"]
|
|
mod update;
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::ffi::OsString;
|
|
|
|
use anyhow::anyhow;
|
|
|
|
use nonempty::NonEmpty;
|
|
use radicle::cob::patch::PatchId;
|
|
use radicle::cob::{patch, Label};
|
|
use radicle::prelude::*;
|
|
use radicle::storage::git::transport;
|
|
|
|
use crate::git::Rev;
|
|
use crate::terminal as term;
|
|
use crate::terminal::args::{string, Args, Error, Help};
|
|
use crate::terminal::patch::Message;
|
|
|
|
pub const HELP: Help = Help {
|
|
name: "patch",
|
|
description: "Manage patches",
|
|
version: env!("CARGO_PKG_VERSION"),
|
|
usage: r#"
|
|
Usage
|
|
|
|
rad patch [<option>...]
|
|
rad patch list [--all|--merged|--open|--archived|--draft|--authored] [--author <did>]... [<option>...]
|
|
rad patch show <patch-id> [<option>...]
|
|
rad patch archive <patch-id> [<option>...]
|
|
rad patch update <patch-id> [<option>...]
|
|
rad patch checkout <patch-id> [<option>...]
|
|
rad patch delete <patch-id> [<option>...]
|
|
rad patch redact <revision-id> [<option>...]
|
|
rad patch label <patch-id> --label <label> [<option>...]
|
|
rad patch unlabel <patch-id> --label <label> [<option>...]
|
|
rad patch ready <patch-id> [--undo] [<option>...]
|
|
rad patch edit <patch-id> [<option>...]
|
|
rad patch set <patch-id> [<option>...]
|
|
rad patch comment <patch-id | revision-id> [<option>...]
|
|
|
|
Show options
|
|
|
|
-p, --patch Show the actual patch diff
|
|
-v, --verbose Show additional information about the patch
|
|
|
|
Comment options
|
|
|
|
-m, --message <string> Provide a comment message via the command-line
|
|
|
|
Edit options
|
|
|
|
-m, --message [<string>] Provide a comment message to the patch or revision (default: prompt)
|
|
|
|
Label options
|
|
|
|
-l, --label Label the patch with the provided label (may be specified multiple times)
|
|
|
|
Unlabel options
|
|
|
|
-l, --label Remove the provided label from the patch (may be specified multiple times)
|
|
|
|
Update options
|
|
|
|
-m, --message [<string>] Provide a comment message to the patch or revision (default: prompt)
|
|
--no-message Leave the patch or revision comment message blank
|
|
|
|
List options
|
|
|
|
--all Show all patches, including merged and archived patches
|
|
--archived Show only archived patches
|
|
--merged Show only merged patches
|
|
--open Show only open patches (default)
|
|
--draft Show only draft patches
|
|
--authored Show only patches that you have authored
|
|
--author <did> Show only patched where the given user is an author
|
|
(may be specified multiple times)
|
|
|
|
Ready options
|
|
|
|
--undo Convert a patch back to a draft
|
|
|
|
Checkout options
|
|
|
|
--revision <id> Checkout the given revision of the patch
|
|
--name <string> Provide a name for the branch to checkout
|
|
-f, --force Checkout the head of the revision, even if the branch already exists
|
|
|
|
Other options
|
|
|
|
-q, --quiet Quiet output
|
|
--help Print help
|
|
"#,
|
|
};
|
|
|
|
#[derive(Debug, Default, PartialEq, Eq)]
|
|
pub enum OperationName {
|
|
Show,
|
|
Update,
|
|
Archive,
|
|
Delete,
|
|
Checkout,
|
|
Comment,
|
|
Ready,
|
|
Label,
|
|
Unlabel,
|
|
#[default]
|
|
List,
|
|
Edit,
|
|
Redact,
|
|
Set,
|
|
}
|
|
|
|
pub struct Filter(fn(&patch::State) -> bool);
|
|
|
|
impl Filter {
|
|
/// Match everything.
|
|
fn all() -> Self {
|
|
Self(|_| true)
|
|
}
|
|
}
|
|
|
|
impl Default for Filter {
|
|
fn default() -> Self {
|
|
Self(|state| matches!(state, patch::State::Open { .. }))
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for Filter {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "Filter(..)")
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum Operation {
|
|
Show {
|
|
patch_id: Rev,
|
|
diff: bool,
|
|
},
|
|
Update {
|
|
patch_id: Rev,
|
|
message: Message,
|
|
},
|
|
Archive {
|
|
patch_id: Rev,
|
|
},
|
|
Ready {
|
|
patch_id: Rev,
|
|
undo: bool,
|
|
},
|
|
Delete {
|
|
patch_id: Rev,
|
|
},
|
|
Checkout {
|
|
patch_id: Rev,
|
|
revision_id: Option<Rev>,
|
|
opts: checkout::Options,
|
|
},
|
|
Comment {
|
|
revision_id: Rev,
|
|
message: Message,
|
|
reply_to: Option<Rev>,
|
|
},
|
|
Label {
|
|
patch_id: Rev,
|
|
labels: NonEmpty<Label>,
|
|
},
|
|
Unlabel {
|
|
patch_id: Rev,
|
|
labels: NonEmpty<Label>,
|
|
},
|
|
List {
|
|
filter: Filter,
|
|
},
|
|
Edit {
|
|
patch_id: Rev,
|
|
message: Message,
|
|
},
|
|
Redact {
|
|
revision_id: Rev,
|
|
},
|
|
Set {
|
|
patch_id: Rev,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct Options {
|
|
pub op: Operation,
|
|
pub announce: bool,
|
|
pub push: bool,
|
|
pub verbose: bool,
|
|
pub quiet: bool,
|
|
pub authored: bool,
|
|
pub authors: Vec<Did>,
|
|
}
|
|
|
|
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 op: Option<OperationName> = None;
|
|
let mut verbose = false;
|
|
let mut quiet = false;
|
|
let mut authored = false;
|
|
let mut authors = vec![];
|
|
let mut announce = false;
|
|
let mut patch_id = None;
|
|
let mut revision_id = None;
|
|
let mut message = Message::default();
|
|
let mut push = true;
|
|
let mut filter = Filter::default();
|
|
let mut diff = false;
|
|
let mut undo = false;
|
|
let mut reply_to: Option<Rev> = None;
|
|
let mut checkout_opts = checkout::Options::default();
|
|
let mut labels = Vec::new();
|
|
|
|
while let Some(arg) = parser.next()? {
|
|
match arg {
|
|
// Options.
|
|
Long("message") | Short('m') => {
|
|
if message != Message::Blank {
|
|
// We skip this code when `no-message` is specified.
|
|
let txt: String = term::args::string(&parser.value()?);
|
|
message.append(&txt);
|
|
}
|
|
}
|
|
Long("no-message") => {
|
|
message = Message::Blank;
|
|
}
|
|
Long("announce") => {
|
|
announce = true;
|
|
}
|
|
Long("no-announce") => {
|
|
announce = false;
|
|
}
|
|
Long("push") => {
|
|
push = true;
|
|
}
|
|
Long("no-push") => {
|
|
push = false;
|
|
}
|
|
|
|
// Show options.
|
|
Long("patch") | Short('p') if op == Some(OperationName::Show) => {
|
|
diff = true;
|
|
}
|
|
|
|
// Ready options.
|
|
Long("undo") if op == Some(OperationName::Ready) => {
|
|
undo = true;
|
|
}
|
|
|
|
// Update options.
|
|
Long("revision") if op == Some(OperationName::Update) => {
|
|
let val = parser.value()?;
|
|
let rev = term::args::oid(&val)?;
|
|
|
|
revision_id = Some(rev);
|
|
}
|
|
|
|
// Comment options.
|
|
Long("reply-to") if op == Some(OperationName::Comment) => {
|
|
let val = parser.value()?;
|
|
let rev = term::args::oid(&val)?;
|
|
|
|
reply_to = Some(rev);
|
|
}
|
|
|
|
// Checkout options
|
|
Long("revision") if op == Some(OperationName::Checkout) => {
|
|
let val = parser.value()?;
|
|
let rev = term::args::oid(&val)?;
|
|
|
|
revision_id = Some(rev);
|
|
}
|
|
|
|
Long("force") | Short('f') if op == Some(OperationName::Checkout) => {
|
|
checkout_opts.force = true;
|
|
}
|
|
|
|
Long("name") if op == Some(OperationName::Checkout) => {
|
|
let val = parser.value()?;
|
|
checkout_opts.name = Some(term::args::refstring("name", val)?);
|
|
}
|
|
|
|
// Un/Label options.
|
|
Short('l') | Long("label")
|
|
if matches!(op, Some(OperationName::Label | OperationName::Unlabel)) =>
|
|
{
|
|
let val = parser.value()?;
|
|
let name = term::args::string(&val);
|
|
let label = Label::new(name)?;
|
|
|
|
labels.push(label);
|
|
}
|
|
|
|
// List options.
|
|
Long("all") => {
|
|
filter = Filter::all();
|
|
}
|
|
Long("draft") => {
|
|
filter = Filter(|s| s == &patch::State::Draft);
|
|
}
|
|
Long("archived") => {
|
|
filter = Filter(|s| s == &patch::State::Archived);
|
|
}
|
|
Long("merged") => {
|
|
filter = Filter(|s| matches!(s, patch::State::Merged { .. }));
|
|
}
|
|
Long("open") => {
|
|
filter = Filter(|s| matches!(s, patch::State::Open { .. }));
|
|
}
|
|
Long("authored") => {
|
|
authored = true;
|
|
}
|
|
Long("author") if op == Some(OperationName::List) => {
|
|
authors.push(term::args::did(&parser.value()?)?);
|
|
}
|
|
|
|
// Common.
|
|
Long("verbose") | Short('v') => {
|
|
verbose = true;
|
|
}
|
|
Long("quiet") | Short('q') => {
|
|
quiet = true;
|
|
}
|
|
Long("help") => {
|
|
return Err(Error::HelpManual { name: "rad-patch" }.into());
|
|
}
|
|
Short('h') => {
|
|
return Err(Error::Help.into());
|
|
}
|
|
|
|
Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
|
|
"l" | "list" => op = Some(OperationName::List),
|
|
"s" | "show" => op = Some(OperationName::Show),
|
|
"u" | "update" => op = Some(OperationName::Update),
|
|
"d" | "delete" => op = Some(OperationName::Delete),
|
|
"c" | "checkout" => op = Some(OperationName::Checkout),
|
|
"a" | "archive" => op = Some(OperationName::Archive),
|
|
"y" | "ready" => op = Some(OperationName::Ready),
|
|
"e" | "edit" => op = Some(OperationName::Edit),
|
|
"r" | "redact" => op = Some(OperationName::Redact),
|
|
"label" => op = Some(OperationName::Label),
|
|
"unlabel" => op = Some(OperationName::Unlabel),
|
|
"comment" => op = Some(OperationName::Comment),
|
|
"set" => op = Some(OperationName::Set),
|
|
unknown => anyhow::bail!("unknown operation '{}'", unknown),
|
|
},
|
|
Value(val) if op == Some(OperationName::Redact) => {
|
|
let rev = term::args::oid(&val)?;
|
|
revision_id = Some(rev);
|
|
}
|
|
Value(val)
|
|
if patch_id.is_none()
|
|
&& [
|
|
Some(OperationName::Show),
|
|
Some(OperationName::Update),
|
|
Some(OperationName::Delete),
|
|
Some(OperationName::Archive),
|
|
Some(OperationName::Ready),
|
|
Some(OperationName::Checkout),
|
|
Some(OperationName::Comment),
|
|
Some(OperationName::Edit),
|
|
Some(OperationName::Set),
|
|
Some(OperationName::Label),
|
|
Some(OperationName::Unlabel),
|
|
]
|
|
.contains(&op) =>
|
|
{
|
|
let val = string(&val);
|
|
patch_id = Some(Rev::from(val));
|
|
}
|
|
_ => return Err(anyhow::anyhow!(arg.unexpected())),
|
|
}
|
|
}
|
|
|
|
let op = match op.unwrap_or_default() {
|
|
OperationName::List => Operation::List { filter },
|
|
OperationName::Show => Operation::Show {
|
|
patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
|
|
diff,
|
|
},
|
|
OperationName::Delete => Operation::Delete {
|
|
patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
|
|
},
|
|
OperationName::Update => Operation::Update {
|
|
patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
|
|
message,
|
|
},
|
|
OperationName::Archive => Operation::Archive {
|
|
patch_id: patch_id.ok_or_else(|| anyhow!("a patch id must be provided"))?,
|
|
},
|
|
OperationName::Checkout => Operation::Checkout {
|
|
patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
|
|
revision_id,
|
|
opts: checkout_opts,
|
|
},
|
|
OperationName::Comment => Operation::Comment {
|
|
revision_id: patch_id
|
|
.ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
|
|
message,
|
|
reply_to,
|
|
},
|
|
OperationName::Ready => Operation::Ready {
|
|
patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
|
|
undo,
|
|
},
|
|
OperationName::Edit => Operation::Edit {
|
|
patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
|
|
message,
|
|
},
|
|
OperationName::Redact => Operation::Redact {
|
|
revision_id: revision_id.ok_or_else(|| anyhow!("a revision must be provided"))?,
|
|
},
|
|
OperationName::Label => Operation::Label {
|
|
patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
|
|
labels: NonEmpty::from_vec(labels)
|
|
.ok_or_else(|| anyhow!("at least one label must be specified"))?,
|
|
},
|
|
OperationName::Unlabel => Operation::Unlabel {
|
|
patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
|
|
labels: NonEmpty::from_vec(labels)
|
|
.ok_or_else(|| anyhow!("at least one label must be specified"))?,
|
|
},
|
|
OperationName::Set => Operation::Set {
|
|
patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
|
|
},
|
|
};
|
|
|
|
Ok((
|
|
Options {
|
|
op,
|
|
push,
|
|
verbose,
|
|
quiet,
|
|
announce,
|
|
authored,
|
|
authors,
|
|
},
|
|
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 repository = profile.storage.repository(id)?;
|
|
|
|
transport::local::register(profile.storage.clone());
|
|
|
|
match options.op {
|
|
Operation::List { filter: Filter(f) } => {
|
|
let mut authors: BTreeSet<Did> = options.authors.iter().cloned().collect();
|
|
if options.authored {
|
|
authors.insert(profile.did());
|
|
}
|
|
list::run(f, authors, &repository, &profile)?;
|
|
}
|
|
Operation::Show { patch_id, diff } => {
|
|
let patch_id = patch_id.resolve(&repository.backend)?;
|
|
show::run(
|
|
&patch_id,
|
|
diff,
|
|
options.verbose,
|
|
&profile,
|
|
&repository,
|
|
&workdir,
|
|
)?;
|
|
}
|
|
Operation::Update {
|
|
ref patch_id,
|
|
ref message,
|
|
} => {
|
|
let patch_id = patch_id.resolve(&repository.backend)?;
|
|
update::run(patch_id, message.clone(), &profile, &repository, &workdir)?;
|
|
}
|
|
Operation::Archive { ref patch_id } => {
|
|
let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
|
|
archive::run(&patch_id, &profile, &repository)?;
|
|
}
|
|
Operation::Ready { ref patch_id, undo } => {
|
|
let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
|
|
ready::run(&patch_id, undo, &profile, &repository)?;
|
|
}
|
|
Operation::Delete { patch_id } => {
|
|
let patch_id = patch_id.resolve::<PatchId>(&repository.backend)?;
|
|
delete::run(&patch_id, &profile, &repository)?;
|
|
}
|
|
Operation::Checkout {
|
|
patch_id,
|
|
revision_id,
|
|
opts,
|
|
} => {
|
|
let patch_id = patch_id.resolve::<radicle::git::Oid>(&repository.backend)?;
|
|
let revision_id = revision_id
|
|
.map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
|
|
.transpose()?
|
|
.map(patch::RevisionId::from);
|
|
checkout::run(
|
|
&patch::PatchId::from(patch_id),
|
|
revision_id,
|
|
&repository,
|
|
&workdir,
|
|
opts,
|
|
)?;
|
|
}
|
|
Operation::Comment {
|
|
revision_id,
|
|
message,
|
|
reply_to,
|
|
} => {
|
|
comment::run(
|
|
revision_id,
|
|
message,
|
|
reply_to,
|
|
options.quiet,
|
|
&repository,
|
|
&profile,
|
|
)?;
|
|
}
|
|
Operation::Edit { patch_id, message } => {
|
|
let patch_id = patch_id.resolve(&repository.backend)?;
|
|
edit::run(&patch_id, message, &profile, &repository)?;
|
|
}
|
|
Operation::Redact { revision_id } => {
|
|
redact::run(&revision_id, &profile, &repository)?;
|
|
}
|
|
Operation::Label { patch_id, labels } => {
|
|
let patch_id = patch_id.resolve(&repository.backend)?;
|
|
label::add(&patch_id, labels, &profile, &repository)?;
|
|
}
|
|
Operation::Unlabel { patch_id, labels } => {
|
|
let patch_id = patch_id.resolve(&repository.backend)?;
|
|
label::remove(&patch_id, labels, &profile, &repository)?;
|
|
}
|
|
Operation::Set { patch_id } => {
|
|
let patches = radicle::cob::patch::Patches::open(&repository)?;
|
|
let patch_id = patch_id.resolve(&repository.backend)?;
|
|
let patch = patches
|
|
.get(&patch_id)?
|
|
.ok_or_else(|| anyhow!("patch {patch_id} not found"))?;
|
|
|
|
radicle::rad::setup_patch_upstream(&patch_id, *patch.head(), &workdir, true)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|