remote-helper: Add 'patch.target' push option

The remote helper 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.
This commit is contained in:
Adrian Duke 2026-05-12 13:36:06 +01:00 committed by Fintan Halpenny
parent f0c6abc6ea
commit 06e201c927
3 changed files with 83 additions and 47 deletions

View File

@ -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 `<base>..<head>`, where the shortened OID is used when not

View File

@ -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::<cob::patch::TargetBranch>().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()));
}

View File

@ -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,15 +788,16 @@ where
Signer: crypto::signature::Verifier<crypto::Signature>,
{
let head = *src;
let dst = dst.with_namespace(nid.into());
let namespaced_dst = dst.with_namespace(nid.into());
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(dst.as_str())
.find_reference(namespaced_dst.as_str())
.ok()
.map(|old| old.peel_to_commit().map(|c| c.id().into()))
.transpose()?
@ -815,7 +805,7 @@ where
push_ref(
src,
&dst,
&namespaced_dst,
force,
stored.raw(),
verbosity,
@ -823,18 +813,22 @@ where
git,
)?;
if let Some(old) = old {
let proj = stored.project()?;
let master = &*git::fmt::Qualified::from(git::fmt::lit::refs_heads(proj.default_branch()));
old
};
// If we're pushing to the project's default branch, we want to see if any patches got
if let Some(old) = old {
let identity = stored.identity()?;
let crefs = identity.doc().canonical_refs()?;
let rules = crefs.rules();
let me = Did::from(nid);
// 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<Signer>(
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<Signer>(
WriteAs<'_, Signer>,
cob::cache::StoreWriter,
>,
identity: &radicle::identity::Identity,
) -> Result<(), Error>
where
Signer: crypto::signature::Keypair<VerifyingKey = crypto::PublicKey>,
@ -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::<Vec<_>>();
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<Signer>(
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<Signer>(
cob::cache::StoreWriter,
>,
signer: &Signer,
identity: &radicle::identity::Identity,
) -> Result<(), Error>
where
Signer: crypto::signature::Keypair<VerifyingKey = crypto::PublicKey>,
@ -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::<Vec<_>>();
for (id, patch) in open {
// Later revisions are more likely to be merged, so we build the list backwards.
let revisions = patch