diff --git a/CHANGELOG.md b/CHANGELOG.md
index 674858b9..2d1d22c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -69,6 +69,13 @@ COB type names and payload IDs remain unchanged for backwards compatibility.
## New Features
+- The remote helper (`git-remote-rad`) now supports the push option
+ `patch.target`. This allows users to explicitly specify a target canonical
+ reference when opening or updating a patch. For example, to open a patch that
+ targets the branch "backport", use `git push -o patch.target=refs/heads/backport`.
+ Furthermore, strict merge and revert isolation is now enforced: patches are
+ only marked as merged or reverted if the commits are pushed to the target
+ branch of the patch explicitly.
- Teach `rad patch show` to show the full commit range for each revision.
Previously, it would only show the head of the range, but not the base.
It now shows `..
`, where the shortened OID is used when not
diff --git a/crates/radicle-remote-helper/src/main.rs b/crates/radicle-remote-helper/src/main.rs
index e3c14b21..5fc45195 100644
--- a/crates/radicle-remote-helper/src/main.rs
+++ b/crates/radicle-remote-helper/src/main.rs
@@ -207,6 +207,8 @@ struct Options {
message: cli::patch::Message,
/// Create a branch and set its upstream when opening a patch.
branch: Branch,
+ /// Patch target to use, when opening or updating a patch.
+ target: cob::patch::MergeTarget,
verbosity: Verbosity,
}
@@ -439,6 +441,12 @@ fn push_option(args: &[&str], opts: &mut Options) -> Result<(), Error> {
"patch.branch" => {
opts.branch = Branch::Provided(git::fmt::RefString::try_from(val)?)
}
+ "patch.target" => {
+ let target = val.parse::().map_err(|e| {
+ Error::UnsupportedPushOption(format!("invalid patch.target '{val}': {e}"))
+ })?;
+ opts.target = cob::patch::MergeTarget::Branch(target);
+ }
other => {
return Err(Error::UnsupportedPushOption(other.to_owned()));
}
diff --git a/crates/radicle-remote-helper/src/push.rs b/crates/radicle-remote-helper/src/push.rs
index 61c1f901..fd825311 100644
--- a/crates/radicle-remote-helper/src/push.rs
+++ b/crates/radicle-remote-helper/src/push.rs
@@ -11,7 +11,6 @@ use std::{assert_eq, io};
use radicle::cob::store::access::WriteAs;
use thiserror::Error;
-use radicle::Profile;
use radicle::cob;
use radicle::cob::object::ParseObjectId;
use radicle::cob::patch;
@@ -24,6 +23,7 @@ use radicle::node::NodeId;
use radicle::storage;
use radicle::storage::git::transport::local::Url;
use radicle::storage::{ReadRepository, SignRepository as _, WriteRepository};
+use radicle::{Profile, identity};
use radicle::{git, rad};
use radicle_cli::terminal as term;
@@ -114,6 +114,9 @@ pub(super) enum Error {
UnknownObjectType { oid: git::Oid },
#[error(transparent)]
FindObjects(#[from] git::canonical::error::FindObjectsError),
+ /// Default branch error.
+ #[error(transparent)]
+ DefaultBranch(#[from] radicle::identity::doc::DefaultBranchError),
/// Error sending pack from the working copy to storage.
#[error(
@@ -579,23 +582,9 @@ where
term::patch::get_create_message(opts.message, &stored.backend, &base.into(), &head.into())?;
let patch = if opts.draft {
- patches.draft(
- title,
- &description,
- patch::MergeTarget::default(),
- base,
- *head,
- &[],
- )
+ patches.draft(title, &description, opts.target.clone(), base, *head, &[])
} else {
- patches.create(
- title,
- &description,
- patch::MergeTarget::default(),
- base,
- *head,
- &[],
- )
+ patches.create(title, &description, opts.target.clone(), base, *head, &[])
}?;
let action = if patch.is_draft() {
@@ -799,42 +788,47 @@ where
Signer: crypto::signature::Verifier,
{
let head = *src;
- let dst = dst.with_namespace(nid.into());
+ let namespaced_dst = dst.with_namespace(nid.into());
- // In some rare cases, pushing to the default branch is a new action for a delegate.
- // If this is the case, use the canonical head before the push to determine the old side.
- //
- // Note that this is only important for reverting and merging, but must happen before the push.
- let old = stored
- .backend
- .find_reference(dst.as_str())
- .ok()
- .map(|old| old.peel_to_commit().map(|c| c.id().into()))
- .transpose()?
- .or_else(|| stored.canonical_head().map(|(_, oid)| oid).ok());
+ let old = {
+ // In some rare cases, pushing to the default branch is a new action for a delegate.
+ // If this is the case, use the canonical head before the push to determine the old side.
+ //
+ // Note that this is only important for reverting and merging, but must happen before the push.
+ let old = stored
+ .backend
+ .find_reference(namespaced_dst.as_str())
+ .ok()
+ .map(|old| old.peel_to_commit().map(|c| c.id().into()))
+ .transpose()?
+ .or_else(|| stored.canonical_head().map(|(_, oid)| oid).ok());
- push_ref(
- src,
- &dst,
- force,
- stored.raw(),
- verbosity,
- expected_refs,
- git,
- )?;
+ push_ref(
+ src,
+ &namespaced_dst,
+ force,
+ stored.raw(),
+ verbosity,
+ expected_refs,
+ git,
+ )?;
+
+ old
+ };
if let Some(old) = old {
- let proj = stored.project()?;
- let master = &*git::fmt::Qualified::from(git::fmt::lit::refs_heads(proj.default_branch()));
+ let identity = stored.identity()?;
+ let crefs = identity.doc().canonical_refs()?;
+ let rules = crefs.rules();
+ let me = Did::from(nid);
- // If we're pushing to the project's default branch, we want to see if any patches got
+ // If we're pushing to a valid canonical branch, we want to see if any patches got
// merged or reverted, and if so, update the patch COB.
- if &*dst.strip_namespace() == master {
- // Only delegates affect the merge state of the COB.
- if stored.delegates()?.contains(&nid.into()) {
- patch_revert_all(old, head, &stored.backend, &mut patches)?;
- patch_merge_all(old, head, working, &mut patches, signer)?;
- }
+ if let Some((_, rule)) = rules.matches(dst).next()
+ && rule.allowed().contains(&me)
+ {
+ patch_revert_all(old, head, dst, &stored.backend, &mut patches, &identity)?;
+ patch_merge_all(old, head, dst, working, &mut patches, signer, &identity)?;
}
}
Ok(Some(ExplorerResource::Tree { oid: head }))
@@ -844,6 +838,7 @@ where
fn patch_revert_all(
old: git::Oid,
new: git::Oid,
+ pushed_dst: &git::fmt::Qualified,
stored: &git::raw::Repository,
patches: &mut patch::Cache<
'_,
@@ -851,6 +846,7 @@ fn patch_revert_all(
WriteAs<'_, Signer>,
cob::cache::StoreWriter,
>,
+ identity: &radicle::identity::Identity,
) -> Result<(), Error>
where
Signer: crypto::signature::Keypair,
@@ -869,11 +865,15 @@ where
return Ok(());
}
+ let identity_doc = identity.doc();
+
// Get the list of merged patches.
let merged = patches
.merged()?
// Skip patches that failed to load.
.filter_map(|patch| patch.ok())
+ // Only consider patches that target the destination branch
+ .filter(|(id, patch)| merge_targets_match(pushed_dst, identity_doc, id, patch))
.collect::>();
for (id, patch) in merged {
@@ -907,10 +907,25 @@ where
Ok(())
}
+fn merge_targets_match(
+ pushed_target: &git::fmt::Qualified,
+ doc: &identity::Doc,
+ id: &patch::PatchId,
+ patch: &patch::Patch,
+) -> bool {
+ patch
+ .is_targeted_by(doc, pushed_target)
+ .unwrap_or_else(|e| {
+ log::warn!("Failed to resolve merge target for patch {}: {}", id, e);
+ false
+ })
+}
+
/// Merge all patches that have been included in the base branch.
fn patch_merge_all(
old: git::Oid,
new: git::Oid,
+ pushed_dst: &git::fmt::Qualified,
working: &git::raw::Repository,
patches: &mut patch::Cache<
'_,
@@ -919,6 +934,7 @@ fn patch_merge_all(
cob::cache::StoreWriter,
>,
signer: &Signer,
+ identity: &radicle::identity::Identity,
) -> Result<(), Error>
where
Signer: crypto::signature::Keypair,
@@ -937,12 +953,17 @@ where
return Ok(());
}
+ let identity_doc = identity.doc();
+
let open = patches
.opened()?
.chain(patches.drafted()?)
// Skip patches that failed to load.
.filter_map(|patch| patch.ok())
+ // Only consider patches that target the destination branch
+ .filter(|(id, patch)| merge_targets_match(pushed_dst, identity_doc, id, patch))
.collect::>();
+
for (id, patch) in open {
// Later revisions are more likely to be merged, so we build the list backwards.
let revisions = patch