remote-helper: allow any revision in push src

This allows any revision that can resolve to an object. The `src` side of the
refspec is resolved to its corresponding `Oid` which is further used for remote
helper pushing and patch creation/update.

Co-authored-by: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
This commit is contained in:
Tobias Hunger 2025-06-29 09:52:38 +00:00 committed by Fintan Halpenny
parent da72557cf7
commit a9f75d47ec
1 changed files with 76 additions and 73 deletions

View File

@ -124,29 +124,47 @@ pub enum Error {
/// Push command. /// Push command.
enum Command { enum Command {
/// Update ref. /// Update ref.
Push(git::Refspec<git::RefString, git::RefString>), Push(git::Refspec<git::Oid, git::RefString>),
/// Delete ref. /// Delete ref.
Delete(git::RefString), Delete(git::RefString),
} }
#[derive(Debug, thiserror::Error)]
enum CommandError {
#[error("expected refspec of the form `[<src>]:<dst>`, got {rev}")]
Empty { rev: String },
#[error("failed to parse destination reference ({rev}): {err}")]
Delete {
rev: String,
#[source]
err: git::fmt::Error,
},
#[error("failed to parse source revision ({rev}): {source}")]
Revision {
rev: String,
source: git::raw::Error,
},
}
impl Command { impl Command {
/// Return the destination refname. /// Parse a `Command` given the input string, expected to be of the form
fn dst(&self) -> &git::RefStr { /// `[src]:dst`.
match self { ///
Self::Push(rs) => rs.dst.as_refstr(), /// If `src` is not provided, then the `Command` is deleting the `dst`
Self::Delete(rs) => rs, /// reference.
} ///
} /// If the `src` is provided, which can be any Git [revision], then `dst` is
} /// being updating with the `src` value.
///
impl FromStr for Command { /// [revision]: https://git-scm.com/docs/revisions
type Err = git::ext::ref_format::Error; fn parse(s: &str, repo: &git::raw::Repository) -> Result<Self, CommandError> {
fn from_str(s: &str) -> Result<Self, Self::Err> {
let Some((src, dst)) = s.split_once(':') else { let Some((src, dst)) = s.split_once(':') else {
return Err(git::ext::ref_format::Error::Empty); return Err(CommandError::Empty { rev: s.to_string() });
}; };
let dst = git::RefString::try_from(dst)?; let dst = git::RefString::try_from(dst).map_err(|err| CommandError::Delete {
rev: dst.to_string(),
err,
})?;
if src.is_empty() { if src.is_empty() {
Ok(Self::Delete(dst)) Ok(Self::Delete(dst))
@ -156,11 +174,26 @@ impl FromStr for Command {
} else { } else {
(src, false) (src, false)
}; };
let src = git::RefString::try_from(src)?; let src = repo
.revparse_single(src)
.map_err(|err| CommandError::Revision {
rev: src.to_string(),
source: err,
})?
.id()
.into();
Ok(Self::Push(git::Refspec { src, dst, force })) Ok(Self::Push(git::Refspec { src, dst, force }))
} }
} }
/// Return the destination refname.
fn dst(&self) -> &git::RefStr {
match self {
Self::Push(rs) => rs.dst.as_refstr(),
Self::Delete(rs) => rs,
}
}
} }
/// Run a git push command. /// Run a git push command.
@ -210,10 +243,11 @@ pub fn run(
let project = identity.project()?; let project = identity.project()?;
let canonical_ref = git::refs::branch(project.default_branch()); let canonical_ref = git::refs::branch(project.default_branch());
let mut set_canonical_refs: Vec<(git::Qualified, git::Oid)> = Vec::with_capacity(specs.len()); let mut set_canonical_refs: Vec<(git::Qualified, git::Oid)> = Vec::with_capacity(specs.len());
let working = git::raw::Repository::open(working)?;
// For each refspec, push a ref or delete a ref. // For each refspec, push a ref or delete a ref.
for spec in specs { for spec in specs {
let Ok(cmd) = Command::from_str(&spec) else { let Ok(cmd) = Command::parse(&spec, &working) else {
return Err(Error::InvalidCommand(format!("push {spec}"))); return Err(Error::InvalidCommand(format!("push {spec}")));
}; };
let result = match &cmd { let result = match &cmd {
@ -233,7 +267,6 @@ pub fn run(
.map_err(Error::from) .map_err(Error::from)
} }
Command::Push(git::Refspec { src, dst, force }) => { Command::Push(git::Refspec { src, dst, force }) => {
let working = git::raw::Repository::open(working)?;
let patches = crate::patches_mut(profile, stored)?; let patches = crate::patches_mut(profile, stored)?;
if dst == &*rad::PATCHES_REFNAME { if dst == &*rad::PATCHES_REFNAME {
@ -284,22 +317,21 @@ pub fn run(
// canonical branch. // canonical branch.
if let Some(mut canonical) = rules.canonical(dst.clone(), stored)? { if let Some(mut canonical) = rules.canonical(dst.clone(), stored)? {
if canonical.is_allowed(&me) { if canonical.is_allowed(&me) {
let head = working.find_reference(src.as_str())?; let head = *src;
let head = head.peel_to_commit()?.id(); let converges = canonical.converges(&working, (&me, &head))?;
let converges =
canonical.converges(&working, (&me, &head.into()))?;
// If `canonical` is empty then we're creating a new reference. // If `canonical` is empty then we're creating a new reference.
// If we're the only delegate then we need to modify our vote. // If we're the only delegate then we need to modify our vote.
if converges || canonical.has_no_tips() || canonical.is_only(&me) { if converges || canonical.has_no_tips() || canonical.is_only(&me) {
canonical.modify_vote(me, head.into()); canonical.modify_vote(me, head);
} }
match canonical.quorum(&working) { match canonical.quorum(&working) {
Ok((dst, canonical_oid)) => { Ok((dst, canonical_oid)) => {
// Canonical head is an ancestor of head. // Canonical head is an ancestor of head.
let is_ff = head == *canonical_oid let is_ff = head == canonical_oid
|| working.graph_descendant_of(head, *canonical_oid)?; || working
.graph_descendant_of(*head, *canonical_oid)?;
if !is_ff && !converges { if !is_ff && !converges {
if hints { if hints {
@ -312,10 +344,7 @@ pub fn run(
and try again", and try again",
); );
} }
return Err(Error::HeadsDiverge( return Err(Error::HeadsDiverge(head, canonical_oid));
head.into(),
canonical_oid,
));
} }
set_canonical_refs set_canonical_refs
.push((dst.clone().to_owned(), canonical_oid)); .push((dst.clone().to_owned(), canonical_oid));
@ -421,7 +450,7 @@ pub fn run(
/// Open a new patch. /// Open a new patch.
fn patch_open<G>( fn patch_open<G>(
src: &git::RefStr, src: &git::Oid,
upstream: &git::RefString, upstream: &git::RefString,
nid: &NodeId, nid: &NodeId,
working: &git::raw::Repository, working: &git::raw::Repository,
@ -437,9 +466,8 @@ fn patch_open<G>(
where where
G: crypto::signature::Signer<crypto::Signature>, G: crypto::signature::Signer<crypto::Signature>,
{ {
let reference = working.find_reference(src.as_str())?; let head = *src;
let commit = reference.peel_to_commit()?; let dst = git::refs::storage::staging::patch(nid, head);
let dst = git::refs::storage::staging::patch(nid, commit.id());
// Before creating the patch, we must push the associated git objects to storage. // Before creating the patch, we must push the associated git objects to storage.
// Unfortunately, we don't have an easy way to transfer the missing objects without // Unfortunately, we don't have an easy way to transfer the missing objects without
@ -451,7 +479,6 @@ where
push_ref(src, &dst, false, working, stored.raw())?; push_ref(src, &dst, false, working, stored.raw())?;
let (_, target) = stored.canonical_head()?; let (_, target) = stored.canonical_head()?;
let head = commit.id().into();
let base = if let Some(base) = opts.base { let base = if let Some(base) = opts.base {
base.resolve(working)? base.resolve(working)?
} else { } else {
@ -469,7 +496,7 @@ where
&description, &description,
patch::MergeTarget::default(), patch::MergeTarget::default(),
base, base,
commit.id(), head,
&[], &[],
signer, signer,
) )
@ -479,7 +506,7 @@ where
&description, &description,
patch::MergeTarget::default(), patch::MergeTarget::default(),
base, base,
commit.id(), head,
&[], &[],
signer, signer,
) )
@ -506,14 +533,13 @@ where
let refname = git::refs::patch(&patch).with_namespace(nid.into()); let refname = git::refs::patch(&patch).with_namespace(nid.into());
let _ = stored.raw().reference( let _ = stored.raw().reference(
refname.as_str(), refname.as_str(),
commit.id(), *head,
true, true,
"Create reference for patch head", "Create reference for patch head",
)?; )?;
// Setup current branch so that pushing updates the patch. // Setup current branch so that pushing updates the patch.
if let Some(branch) = if let Some(branch) = rad::setup_patch_upstream(&patch, head, working, upstream, false)?
rad::setup_patch_upstream(&patch, commit.id().into(), working, upstream, false)?
{ {
if let Some(name) = branch.name()? { if let Some(name) = branch.name()? {
if profile.hints() { if profile.hints() {
@ -544,7 +570,7 @@ where
/// Update an existing patch. /// Update an existing patch.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn patch_update<G>( fn patch_update<G>(
src: &git::RefStr, src: &git::Oid,
dst: &git::Qualified, dst: &git::Qualified,
force: bool, force: bool,
oid: &git::Oid, oid: &git::Oid,
@ -561,8 +587,7 @@ fn patch_update<G>(
where where
G: crypto::signature::Signer<crypto::Signature>, G: crypto::signature::Signer<crypto::Signature>,
{ {
let reference = working.find_reference(src.as_str())?; let commit = *src;
let commit = reference.peel_to_commit()?;
let patch_id = radicle::cob::ObjectId::from(oid); let patch_id = radicle::cob::ObjectId::from(oid);
let dst = dst.with_namespace(nid.into()); let dst = dst.with_namespace(nid.into());
@ -573,22 +598,17 @@ where
}; };
// Don't update patch if it already has a revision matching this commit. // Don't update patch if it already has a revision matching this commit.
if patch.revisions().any(|(_, r)| *r.head() == commit.id()) { if patch.revisions().any(|(_, r)| r.head() == commit) {
return Ok(None); return Ok(None);
} }
let (latest_id, latest) = patch.latest(); let (latest_id, latest) = patch.latest();
let latest = latest.clone(); let latest = latest.clone();
let message = term::patch::get_update_message( let message = term::patch::get_update_message(opts.message, &stored.backend, &latest, &commit)?;
opts.message,
&stored.backend,
&latest,
&commit.id().into(),
)?;
let (_, target) = stored.canonical_head()?; let (_, target) = stored.canonical_head()?;
let head: git::Oid = commit.id().into(); let head: git::Oid = commit;
let base = if let Some(base) = opts.base { let base = if let Some(base) = opts.base {
base.resolve(working)? base.resolve(working)?
} else { } else {
@ -630,7 +650,7 @@ where
} }
fn push<G>( fn push<G>(
src: &git::RefStr, src: &git::Oid,
dst: &git::Qualified, dst: &git::Qualified,
force: bool, force: bool,
nid: &NodeId, nid: &NodeId,
@ -645,18 +665,7 @@ fn push<G>(
where where
G: crypto::signature::Signer<crypto::Signature>, G: crypto::signature::Signer<crypto::Signature>,
{ {
let head = match working.find_reference(src.as_str()) { let head = *src;
Ok(obj) => obj.peel_to_commit()?,
Err(e) => {
if let Ok(oid) = git::Oid::from_str(src.as_str()) {
working.find_commit(oid.into())?
} else {
return Err(e.into());
}
}
}
.id();
let dst = dst.with_namespace(nid.into()); let dst = dst.with_namespace(nid.into());
// It's ok for the destination reference to be unknown, eg. when pushing a new branch. // It's ok for the destination reference to be unknown, eg. when pushing a new branch.
let old = stored.backend.find_reference(dst.as_str()).ok(); let old = stored.backend.find_reference(dst.as_str()).ok();
@ -673,18 +682,12 @@ where
let old = old.peel_to_commit()?.id(); let old = old.peel_to_commit()?.id();
// Only delegates affect the merge state of the COB. // Only delegates affect the merge state of the COB.
if stored.delegates()?.contains(&nid.into()) { if stored.delegates()?.contains(&nid.into()) {
patch_revert_all( patch_revert_all(old.into(), head, &stored.backend, &mut patches, signer)?;
old.into(), patch_merge_all(old.into(), head, working, &mut patches, signer)?;
head.into(),
&stored.backend,
&mut patches,
signer,
)?;
patch_merge_all(old.into(), head.into(), working, &mut patches, signer)?;
} }
} }
} }
Ok(Some(ExplorerResource::Tree { oid: head.into() })) Ok(Some(ExplorerResource::Tree { oid: head }))
} }
/// Revert all patches that are no longer included in the base branch. /// Revert all patches that are no longer included in the base branch.
@ -845,7 +848,7 @@ where
/// Push a single reference to storage. /// Push a single reference to storage.
fn push_ref( fn push_ref(
src: &git::RefStr, src: &git::Oid,
dst: &git::Namespaced, dst: &git::Namespaced,
force: bool, force: bool,
working: &git::raw::Repository, working: &git::raw::Repository,