cli: Support revision id in `rad merge`
Revision IDs are globally unique. Users will be able to use the Revision ID in place of its patch ID. Make `rad merge` refuse to merge an older revision unless the `--force` option is given.
This commit is contained in:
parent
8404b5925d
commit
0b7f169c8b
|
|
@ -54,3 +54,36 @@ a07ef77 R1 (27857ec) -> R2 (f6484e0)
|
|||
|
||||
✓ Patch a07ef77 updated to 737dc47d9eba730c5db8a8e33c41c7955f9093de
|
||||
```
|
||||
|
||||
Great, all fixed up, lets merge the code.
|
||||
|
||||
```
|
||||
$ git checkout master
|
||||
Your branch is up to date with 'rad/master'.
|
||||
$ rad merge 737dc47d9eba730c5db8a8e33c41c7955f9093de
|
||||
Merging a07ef77 R2 (f6484e0) by did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk into master (f2de534) via fast-forward...
|
||||
Running `git merge --ff-only f6484e0f43e48a8983b9b39bf9bd4cd889f1d520`...
|
||||
Updating f2de534..f6484e0
|
||||
Fast-forward
|
||||
README.md | 0
|
||||
REQUIREMENTS.md | 0
|
||||
2 files changed, 0 insertions(+), 0 deletions(-)
|
||||
create mode 100644 README.md
|
||||
create mode 100644 REQUIREMENTS.md
|
||||
✓ Updated master f2de534 -> f6484e0 via fast-forward
|
||||
✓ Patch state updated, use `rad push` to publish
|
||||
```
|
||||
|
||||
The patch is now merged and closed :).
|
||||
|
||||
```
|
||||
$ rad patch
|
||||
╭───────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Define power requirements a07ef77 R2 f6484e0 (flux-capacitor-power, master) ahead 3, behind 0 │
|
||||
├───────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ ● opened by did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk 3 months ago │
|
||||
│ ↑ updated to a07ef7743a32a2e902672ea3526d1db6ee08108a (3e674d1) 3 months ago │
|
||||
│ ↑ updated to 4f15eb5c994edd0bb6be29cb4801aa74308cc628 (27857ec) 3 months ago │
|
||||
│ ✓ merged by did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (you) 3 months ago │
|
||||
╰───────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
use std::ffi::OsString;
|
||||
use std::fmt;
|
||||
use std::fmt::Write;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
|
||||
use crate::git::Rev;
|
||||
use crate::terminal as term;
|
||||
use crate::terminal::args::{string, Args, Error, Help};
|
||||
use radicle::cob::patch::RevisionIx;
|
||||
use radicle::cob::patch::{Patch, PatchId, Patches};
|
||||
use radicle::git;
|
||||
use radicle::prelude::*;
|
||||
|
|
@ -21,14 +19,14 @@ pub const HELP: Help = Help {
|
|||
usage: r#"
|
||||
Usage
|
||||
|
||||
rad merge [<id>] [<option>...]
|
||||
rad merge [<revision-id>] [<option>...]
|
||||
|
||||
To specify a patch to merge, use the fully qualified patch id.
|
||||
To specify a patch revision to merge, use the fully qualified revision id.
|
||||
|
||||
Options
|
||||
|
||||
-f, --force Force merging an older patch revision
|
||||
-i, --interactive Ask for confirmations
|
||||
-r, --revision <number> Revision number to merge, defaults to the latest
|
||||
--help Print help
|
||||
"#,
|
||||
};
|
||||
|
|
@ -71,11 +69,12 @@ pub enum State {
|
|||
Open,
|
||||
Merged,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Options {
|
||||
pub id: Rev,
|
||||
pub revision_id: Rev,
|
||||
pub force: bool,
|
||||
pub interactive: bool,
|
||||
pub revision: Option<RevisionIx>,
|
||||
}
|
||||
|
||||
impl Args for Options {
|
||||
|
|
@ -83,8 +82,8 @@ impl Args for Options {
|
|||
use lexopt::prelude::*;
|
||||
|
||||
let mut parser = lexopt::Parser::from_args(args);
|
||||
let mut id: Option<Rev> = None;
|
||||
let mut revision: Option<RevisionIx> = None;
|
||||
let mut force = false;
|
||||
let mut revision_id = None;
|
||||
let mut interactive = false;
|
||||
|
||||
while let Some(arg) = parser.next()? {
|
||||
|
|
@ -92,30 +91,28 @@ impl Args for Options {
|
|||
Long("help") => {
|
||||
return Err(Error::Help.into());
|
||||
}
|
||||
Long("force") | Short('f') => {
|
||||
force = true;
|
||||
}
|
||||
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 = string(&val);
|
||||
id = Some(Rev::from(val));
|
||||
revision_id = Some(Rev::from(val));
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!(arg.unexpected())),
|
||||
}
|
||||
}
|
||||
|
||||
let revision_id =
|
||||
revision_id.ok_or_else(|| anyhow!("a revision id to merge must be provided"))?;
|
||||
|
||||
Ok((
|
||||
Options {
|
||||
id: id.ok_or_else(|| anyhow!("a patch id to merge must be provided"))?,
|
||||
revision_id,
|
||||
force,
|
||||
interactive,
|
||||
revision,
|
||||
},
|
||||
vec![],
|
||||
))
|
||||
|
|
@ -144,10 +141,27 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
|||
//
|
||||
// Get patch information
|
||||
//
|
||||
let patch_id = options.id.resolve(&repository.backend)?;
|
||||
let revision_id = options.revision_id.resolve(&repository.backend)?;
|
||||
let (patch_id, patch, revision) = patches.find_by_revision(&revision_id)?.ok_or(anyhow!(
|
||||
"no open patch with revision `{}` could be found",
|
||||
&revision_id
|
||||
))?;
|
||||
if !patch.is_open() {
|
||||
anyhow::bail!(
|
||||
"revision's patch must be open for merging, but it is `{}`",
|
||||
patch.state()
|
||||
);
|
||||
}
|
||||
let (last_revision_id, _) = patch
|
||||
.latest()
|
||||
.ok_or(anyhow!("patch must have atleast one unredacted revision"))?;
|
||||
if !options.force && revision_id != *last_revision_id {
|
||||
anyhow::bail!("refusing to merge old patch revision");
|
||||
}
|
||||
|
||||
let mut patch = patches
|
||||
.get_mut(&patch_id)
|
||||
.map_err(|e| anyhow!("couldn't find patch {} locally: {e}", &options.id.clone()))?;
|
||||
.map_err(|e| anyhow!("couldn't find patch {} locally: {e}", &id))?;
|
||||
|
||||
let head = repo.head()?;
|
||||
let branch = head
|
||||
|
|
@ -156,11 +170,6 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
|||
let head_oid = head
|
||||
.target()
|
||||
.ok_or_else(|| anyhow!("cannot merge into detatched head; aborting"))?;
|
||||
let revision_ix = options.revision.unwrap_or_else(|| patch.version());
|
||||
let (revision_id, revision) = patch
|
||||
.revisions()
|
||||
.nth(revision_ix)
|
||||
.ok_or_else(|| anyhow!("revision R{} does not exist", revision_ix))?;
|
||||
|
||||
//
|
||||
// Analyze merge
|
||||
|
|
@ -221,6 +230,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
|||
.to_string(),
|
||||
};
|
||||
|
||||
// SAFETY: The patch has already been fetched by its revision_id.
|
||||
let revision_ix = patch
|
||||
.revisions()
|
||||
.position(|(id_, _)| id_ == &revision_id)
|
||||
.unwrap();
|
||||
term::info!(
|
||||
"{} {} {} {} by {} into {} {} via {}...",
|
||||
term::format::bold("Merging"),
|
||||
|
|
@ -261,7 +275,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
|||
// Update patch COB
|
||||
//
|
||||
// TODO: Don't allow merging the same revision twice?
|
||||
patch.merge(*revision_id, head_oid.into(), &signer)?;
|
||||
patch.merge(revision_id, head_oid.into(), &signer)?;
|
||||
|
||||
term::success!(
|
||||
"Patch state updated, use {} to publish",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ use std::str::FromStr;
|
|||
use anyhow::anyhow;
|
||||
use anyhow::Context as _;
|
||||
|
||||
use radicle::cob::ObjectId;
|
||||
use radicle::crypto::ssh;
|
||||
use radicle::git;
|
||||
use radicle::git::raw as git2;
|
||||
|
|
@ -39,10 +38,13 @@ impl Rev {
|
|||
&self.0
|
||||
}
|
||||
|
||||
/// Resolve the revision to an [`ObjectId`].
|
||||
pub fn resolve(&self, repo: &git2::Repository) -> Result<ObjectId, git2::Error> {
|
||||
/// Resolve the revision to an [`From<git2::Oid>`].
|
||||
pub fn resolve<T>(&self, repo: &git2::Repository) -> Result<T, git2::Error>
|
||||
where
|
||||
T: From<git2::Oid>,
|
||||
{
|
||||
let object = repo.revparse_single(self.as_str())?;
|
||||
Ok(ObjectId::from(object.id()))
|
||||
Ok(object.id().into())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use nonempty::NonEmpty;
|
|||
use radicle_crypto::PublicKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::object;
|
||||
use crate::pruning_fold;
|
||||
|
||||
/// Entry contents.
|
||||
|
|
@ -60,6 +61,12 @@ impl From<EntryId> for Oid {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<&EntryId> for object::ObjectId {
|
||||
fn from(EntryId(id): &EntryId) -> Self {
|
||||
id.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry in the dependency graph for a change
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Entry {
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ pub fn indented(msg: impl fmt::Display) {
|
|||
}
|
||||
|
||||
pub fn subcommand(msg: impl fmt::Display) {
|
||||
println!("{} {}", style("$").dim(), style(msg).dim());
|
||||
println!("{}", style(format!("Running `{msg}`...")).dim());
|
||||
}
|
||||
|
||||
pub fn warning(warning: &str) {
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@ impl Patch {
|
|||
.author
|
||||
}
|
||||
|
||||
/// Get the `Revision` by its `RevisionId`.
|
||||
///
|
||||
/// None is returned if the `Revision` has been redacted (deleted).
|
||||
pub fn revision(&self, id: &RevisionId) -> Option<&Revision> {
|
||||
self.revisions.get(id).and_then(Redactable::get)
|
||||
}
|
||||
|
||||
/// List of patch revisions. The initial changeset is part of the
|
||||
/// first revision.
|
||||
pub fn revisions(&self) -> impl DoubleEndedIterator<Item = (&RevisionId, &Revision)> {
|
||||
|
|
@ -1010,6 +1017,25 @@ impl<'a> Patches<'a> {
|
|||
Ok(state_groups)
|
||||
}
|
||||
|
||||
/// Find the `Patch` containing the given `Revision`.
|
||||
pub fn find_by_revision(
|
||||
&self,
|
||||
id: &RevisionId,
|
||||
) -> Result<Option<(PatchId, Patch, Revision)>, Error> {
|
||||
// Revision may be the patch's first, making it have the same ID.
|
||||
let p_id = ObjectId::from(id);
|
||||
if let Some(p) = self.get(&p_id)? {
|
||||
return Ok(p.revision(id).map(|r| (p_id, p.clone(), r.clone())));
|
||||
}
|
||||
|
||||
let result = self
|
||||
.all()?
|
||||
.into_iter()
|
||||
.filter_map(|result| result.ok())
|
||||
.find_map(|(p_id, p, _)| p.revision(id).map(|r| (p_id, p.clone(), r.clone())));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get a patch.
|
||||
pub fn get(&self, id: &ObjectId) -> Result<Option<Patch>, store::Error> {
|
||||
self.raw.get(id).map(|r| r.map(|(p, _)| p))
|
||||
|
|
@ -1253,8 +1279,8 @@ mod test {
|
|||
|
||||
assert_eq!(patch.clock.get(), 1);
|
||||
|
||||
let id = patch.id;
|
||||
let patch = patches.get(&id).unwrap().unwrap();
|
||||
let patch_id = patch.id;
|
||||
let patch = patches.get(&patch_id).unwrap().unwrap();
|
||||
|
||||
assert_eq!(patch.title(), "My first patch");
|
||||
assert_eq!(patch.description(), "Blah blah blah.");
|
||||
|
|
@ -1263,13 +1289,16 @@ mod test {
|
|||
assert_eq!(patch.target(), target);
|
||||
assert_eq!(patch.version(), 0);
|
||||
|
||||
let (_, revision) = patch.latest().unwrap();
|
||||
let (rev_id, revision) = patch.latest().unwrap();
|
||||
|
||||
assert_eq!(revision.author.id(), &author);
|
||||
assert_eq!(revision.description(), "");
|
||||
assert_eq!(revision.discussion.len(), 0);
|
||||
assert_eq!(revision.oid, oid);
|
||||
assert_eq!(revision.base, base);
|
||||
|
||||
let (id, _, _) = patches.find_by_revision(rev_id).unwrap().unwrap();
|
||||
assert_eq!(id, patch_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in New Issue