#![allow(clippy::too_many_arguments)] mod canonical; mod error; use std::collections::HashMap; use std::process::ExitStatus; use std::str::FromStr; 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; use radicle::cob::patch::cache::Patches as _; use radicle::crypto; use radicle::explorer::ExplorerResource; use radicle::identity::Did; use radicle::node; use radicle::node::NodeId; use radicle::storage; use radicle::storage::git::transport::local::Url; use radicle::storage::{ReadRepository, SignRepository as _, WriteRepository}; use radicle::{git, rad}; use radicle_cli::terminal as term; use crate::service::GitService; use crate::service::NodeSession; use crate::{Options, Verbosity, hint, warn}; #[derive(Debug, Error)] pub(super) enum Error { /// Public key doesn't match the remote namespace we're pushing to. #[error("cannot push to remote namespace owned by {0}")] KeyMismatch(Did), /// No public key is given #[error( "no public key given as a remote namespace, perhaps you are attempting to push to restricted refs" )] NoKey, /// User tried to delete the canonical branch. #[error("refusing to delete default branch ref '{0}'")] DeleteForbidden(git::fmt::RefString), /// Identity document error. #[error("doc: {0}")] Doc(#[from] radicle::identity::doc::DocError), /// Identity payload error. #[error("payload: {0}")] Payload(#[from] radicle::identity::doc::PayloadError), /// Protocol error. #[error("protocol error: {0}")] Protocol(#[from] crate::protocol::Error), /// I/O error. #[error("i/o error: {0}")] Io(#[from] io::Error), /// Invalid reference name. #[error("invalid ref: {0}")] InvalidRef(#[from] radicle::git::fmt::Error), /// Git error. #[error("git: {0}")] Git(#[from] git::raw::Error), /// Storage error. #[error(transparent)] Storage(#[from] radicle::storage::Error), /// Profile error. #[error(transparent)] Profile(#[from] radicle::profile::Error), /// Signer error. #[error(transparent)] Signer(#[from] radicle::profile::SignerError), /// Parse error for object IDs. #[error(transparent)] ParseObjectId(#[from] ParseObjectId), /// Patch COB error. #[error(transparent)] Patch(#[from] radicle::cob::patch::Error), /// Error from COB patch cache. #[error(transparent)] PatchCache(#[from] patch::cache::Error), /// Patch edit message error. #[error(transparent)] PatchEdit(#[from] term::patch::Error), /// Policy config error. #[error("node policy: {0}")] Policy(#[from] node::policy::config::Error), /// Patch not found in store. #[error("patch `{0}` not found")] NotFound(patch::PatchId), /// Revision not found in store. #[error("revision `{0}` not found")] RevisionNotFound(patch::RevisionId), /// Patch is empty. #[error("patch commits are already included in the base branch")] EmptyPatch, /// COB store error. #[error(transparent)] Cob(#[from] radicle::cob::store::Error), /// General repository error. #[error(transparent)] Repository(#[from] radicle::storage::RepositoryError), /// Quorum error. #[error(transparent)] Quorum(#[from] radicle::git::canonical::error::QuorumError), #[error(transparent)] CanonicalRefs(#[from] radicle::identity::doc::CanonicalRefsError), #[error(transparent)] PushAction(#[from] error::PushAction), #[error(transparent)] Canonical(#[from] error::CanonicalUnrecoverable), #[error("could not determine object type for {oid}")] UnknownObjectType { oid: git::Oid }, #[error(transparent)] FindObjects(#[from] git::canonical::error::FindObjectsError), /// Error sending pack from the working copy to storage. #[error( "`git send-pack` failed with exit status {status}, stderr and stdout follow:\n{stderr}\n{stdout}" )] SendPackFailed { status: ExitStatus, stderr: String, stdout: String, }, /// Received an unexpected command after the first `push` command. #[error("unexpected command after first `push`: {0:?}")] UnexpectedCommand(crate::protocol::Command), #[error(transparent)] CommandError(#[from] CommandError), } /// Push command. enum Command { /// Update ref. Push(git::fmt::refspec::Refspec), /// Delete ref. Delete(git::fmt::RefString), } #[derive(Debug, thiserror::Error)] pub(super) enum CommandError { #[error("expected refspec of the form `[]:`, 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 { /// Parse a `Command` given the input string, expected to be of the form /// `[src]:dst`. /// /// If `src` is not provided, then the `Command` is deleting the `dst` /// reference. /// /// If the `src` is provided, which can be any Git [revision], then `dst` is /// being updating with the `src` value. /// /// [revision]: https://git-scm.com/docs/revisions fn parse(s: &str, repo: &git::raw::Repository) -> Result { let Some((src, dst)) = s.split_once(':') else { return Err(CommandError::Empty { rev: s.to_string() }); }; let dst = git::fmt::RefString::try_from(dst).map_err(|err| CommandError::Delete { rev: dst.to_string(), err, })?; if src.is_empty() { Ok(Self::Delete(dst)) } else { let (src, force) = if let Some(stripped) = src.strip_prefix('+') { (stripped, true) } else { (src, false) }; let src = repo .revparse_single(src) .map_err(|err| CommandError::Revision { rev: src.to_string(), source: err, })? .id() .into(); Ok(Self::Push(git::fmt::refspec::Refspec { src, dst, force })) } } /// Return the destination refname. fn dst(&self) -> &git::fmt::RefStr { match self { Self::Push(rs) => rs.dst.as_refstr(), Self::Delete(rs) => rs, } } } enum PushAction { OpenPatch, UpdatePatch { dst: git::fmt::Qualified<'static>, patch: patch::PatchId, }, PushRef { dst: git::fmt::Qualified<'static>, }, } impl PushAction { fn new(dst: &git::fmt::RefString) -> Result { if dst == &*rad::PATCHES_REFNAME { Ok(Self::OpenPatch) } else { let dst = git::fmt::Qualified::from_refstr(dst) .ok_or_else(|| error::PushAction::InvalidRef { refname: dst.clone(), })? .to_owned(); if let Some(oid) = dst.strip_prefix(git::fmt::refname!("refs/heads/patches")) { let patch = git::Oid::from_str(oid) .map_err(|source| error::PushAction::InvalidPatchId { suffix: oid.to_string(), source, }) .map(patch::PatchId::from)?; Ok(Self::UpdatePatch { dst, patch }) } else { Ok(Self::PushRef { dst }) } } } } /// Run a git push command. pub(super) fn run( mut specs: Vec, remote: Option, url: Url, stored: &storage::git::Repository, profile: &Profile, command_reader: &mut crate::protocol::LineReader, opts: Options, expected_refs: &[String], git: &impl GitService, node: &mut impl NodeSession, ) -> Result, Error> { const LOG_MESSAGE: &str = "set-canonical-reference from git-push (radicle)"; // Don't allow push if either of these conditions is true: // // 1. Our key is not in ssh-agent, which means we won't be able to sign the refs. // 2. Our key is not the one loaded in the profile, which means that the signed refs // won't match the remote we're pushing to. // 3. The URL namespace is not set. let nid = url.namespace.ok_or(Error::NoKey).and_then(|ns| { (profile.public_key == ns) .then_some(ns) .ok_or(Error::KeyMismatch(ns.into())) })?; let signer = profile.signer()?; let mut ok = HashMap::new(); let hints = opts.hints || profile.hints(); let mut output = Vec::new(); assert_eq!(signer.public_key(), &nid); // Read all the `push` lines. for line in command_reader.by_ref() { match line?? { crate::protocol::Line::Blank => { // An empty line means end of input. break; } crate::protocol::Line::Valid(crate::protocol::Command::Push(spec)) => { specs.push(spec); } crate::protocol::Line::Valid(command) => return Err(Error::UnexpectedCommand(command)), } } let delegates = stored.delegates()?; let mut set_canonical_refs: Vec<(git::fmt::Qualified, git::canonical::Object)> = Vec::with_capacity(specs.len()); // Rely on the environment variable `GIT_DIR`. let working = git::raw::Repository::open_from_env()?; // For each refspec, push a ref or delete a ref. for spec in specs { let cmd = Command::parse(&spec, &working)?; let result = match &cmd { Command::Delete(dst) => { // Delete refs. let refname = nid.to_namespace().join(dst); let (canonical_ref, _) = &stored.head()?; if *dst == canonical_ref.to_ref_string() && delegates.contains(&Did::from(nid)) { return Err(Error::DeleteForbidden(dst.clone())); } stored .raw() .find_reference(&refname) .and_then(|mut r| r.delete()) .map(|_| None) .map_err(Error::from) } Command::Push(git::fmt::refspec::Refspec { src, dst, force }) => { let signer = profile.signer()?; let patches = crate::patches_mut(profile, stored, &signer)?; let action = PushAction::new(dst)?; match action { PushAction::OpenPatch => patch_open( src, &remote, &nid, &working, stored, patches, profile, opts.clone(), git, ), PushAction::UpdatePatch { dst, patch } => patch_update( src, &dst, *force, patch, &nid, &working, stored, patches, &signer, opts.clone(), expected_refs, git, ), PushAction::PushRef { dst } => { let identity = stored.identity()?; let crefs = identity.doc().canonical_refs()?; let rules = crefs.rules(); let me = Did::from(nid); let explorer = push( src, &dst, *force, &nid, &working, stored, patches, &signer, opts.verbosity, expected_refs, git, )?; // If we're trying to update the canonical head, make sure // we don't diverge from the current head. This only applies // to repos with more than one delegate. // // Note that we *do* allow rolling back to a previous commit on the // canonical branch. if let Some(canonical) = rules.canonical(dst.clone(), stored) { let object = working .find_object(src.into(), None) .map(|obj| git::canonical::Object::new(&obj))? .ok_or(Error::UnknownObjectType { oid: *src })?; let canonical = canonical::Canonical::new(me, object, canonical)?; match canonical.quorum() { Ok(quorum) => set_canonical_refs.push(quorum), Err(e) => canonical::io::handle_error(e)?, } } Ok(explorer) } } } }; match result { // Let Git tooling know that this ref has been pushed. Ok(resource) => { output.push(format!("ok {}", cmd.dst())); ok.insert(spec, resource); } // Let Git tooling know that there was an error pushing the ref. Err(e) => output.push(format!("error {} {e}", cmd.dst())), } } // Sign refs and sync if at least one ref pushed successfully. if !ok.is_empty() { let _ = stored.sign_refs(&signer)?; stored.set_canonical_symbolic_refs(LOG_MESSAGE)?; for (refname, object) in &set_canonical_refs { let oid = object.id(); let kind = object.object_type(); let print_update = || { eprintln!( "{} Canonical reference {} updated to target {kind} {}", term::PREFIX_SUCCESS, term::format::secondary(refname), term::format::secondary(oid), ) }; match stored.backend.refname_to_id(refname.as_str()) { Ok(new) if oid != new => { stored .backend .reference(refname.as_str(), oid.into(), true, LOG_MESSAGE)?; print_update(); } Err(e) if e.code() == git::raw::ErrorCode::NotFound => { stored.backend.reference( refname.as_str(), oid.into(), true, "set-canonical-reference from git-push (radicle)", )?; print_update(); } _ => {} } } if !opts.no_sync { if profile.policies()?.is_seeding(&stored.id)? { // Connect to local node and announce refs to the network. // If our node is not running, we simply skip this step, as the // refs will be announced eventually, when the node restarts. if node.is_running() { // Nb. allow this to fail. The push to local storage was still successful. node.sync(stored, ok.into_values().flatten().collect(), opts, profile) .ok(); } else if hints { hint("offline push, your node is not running"); hint("to sync with the network, run `rad node start`"); } } else if hints { hint("you are not seeding this repository; skipping sync"); } } } Ok(output) } fn patch_base( head: &git::Oid, opts: &Options, stored: &storage::git::Repository, ) -> Result { Ok(if let Some(base) = opts.base { base } else { // Computation of the canonical head is required only if the user // did not specify a base explicitly. This allows the user to // continue updating patches even while the canonical head cannot // be computed, e.g. while they wait for their fellow delegates // to converge and sync. let (_, target) = stored.canonical_head()?; stored.merge_base(&target, head)? }) } /// Before opening or updating patches, we want to evaluate the merge base of the /// patch and the default branch. In order to do that, the respective heads must /// be present in the same Git repository. /// /// Unfortunately, we don't have an easy way to transfer the objects without /// creating a reference (be it in storage or working copy). /// /// We choose to push a temporary reference to storage, which gets deleted on /// [`Drop::drop`]. struct TempPatchRef<'a, G> { stored: &'a storage::git::Repository, reference: git::fmt::Namespaced<'a>, git: &'a G, } impl<'a, G: GitService> TempPatchRef<'a, G> { fn new( stored: &'a storage::git::Repository, head: &git::Oid, nid: &NodeId, git: &'a G, ) -> Self { let reference = git::refs::storage::staging::patch(nid, *head); Self { stored, reference, git, } } fn push(&self, src: &git::Oid, verbosity: Verbosity) -> Result<(), Error> { push_ref( src, &self.reference, false, self.stored.raw(), verbosity, &[], self.git, ) } } impl<'a, G> Drop for TempPatchRef<'a, G> { fn drop(&mut self) { if let Err(err) = self .stored .raw() .find_reference(&self.reference) .and_then(|mut r| r.delete()) { eprintln!( "{} Failed to delete temporary reference {} in storage: {err}", term::PREFIX_WARNING, term::format::tertiary(&self.reference), ); } } } /// Open a new patch. fn patch_open( head: &git::Oid, upstream: &Option, nid: &NodeId, working: &git::raw::Repository, stored: &storage::git::Repository, mut patches: patch::Cache< '_, storage::git::Repository, WriteAs<'_, Signer>, cob::cache::StoreWriter, >, profile: &Profile, opts: Options, git: &S, ) -> Result, Error> where S: GitService, Signer: crypto::signature::Keypair, Signer: crypto::signature::Signer, Signer: crypto::signature::Signer, Signer: crypto::signature::Verifier, { let temp = TempPatchRef::new(stored, head, nid, git); temp.push(head, opts.verbosity)?; let base = patch_base(head, &opts, stored)?; if base == *head { warn(format!( "attempted to create a patch using the commit {head}, but this commit is already included in the base branch" )); return Err(Error::EmptyPatch); } let (title, description) = 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, &[], ) } else { patches.create( title, &description, patch::MergeTarget::default(), base, *head, &[], ) }?; let action = if patch.is_draft() { "drafted" } else { "opened" }; let patch = patch.id; eprintln!( "{} Patch {} {action}", term::PREFIX_SUCCESS, term::format::tertiary(patch), ); // Create long-lived patch head reference, now that we know the Patch ID. // // refs/namespaces//refs/heads/patches/ // let refname = git::refs::patch(&patch).with_namespace(nid.into()); let _ = stored.raw().reference( refname.as_str(), head.into(), true, "Create reference for patch head", )?; if let Some(upstream) = upstream { if let Some(local_branch) = opts.branch.into_branch_name(&patch) { fn strip_refs_heads(qualified: git::fmt::Qualified) -> git::fmt::RefString { let (_refs, _heads, x, xs) = qualified.non_empty_components(); std::iter::once(x).chain(xs).collect() } working.reference( &local_branch, head.into(), true, "Create local branch for patch", )?; let remote_branch = git::refs::workdir::patch_upstream(&patch); let remote_branch = working.reference( &remote_branch, head.into(), true, "Create remote tracking branch for patch", )?; debug_assert!(remote_branch.is_remote()); let local_branch = strip_refs_heads(local_branch); let upstream_branch = git::refs::patch(&patch); git::set_upstream(working, upstream, &local_branch, &upstream_branch)?; eprintln!( "{} Branch {} created", term::PREFIX_SUCCESS, term::format::tertiary(&local_branch), ); hint(format!( "to update, run `git push {upstream} {local_branch}`" )); } // Set up current branch so that pushing updates the patch. else if let Some(branch) = rad::setup_patch_upstream(&patch, *head, working, upstream, false)? { if let Some(name) = branch.name()? { if profile.hints() { // Remove the remote portion of the name, i.e. // rad/patches/deadbeef -> patches/deadbeef let name = name.split_once('/').unwrap_or_default().1; hint(format!( "to update, run `git push` or `git push {upstream} --force-with-lease HEAD:{name}`" )); } } } } Ok(Some(ExplorerResource::Patch { id: patch })) } /// Update an existing patch. #[allow(clippy::too_many_arguments)] fn patch_update( head: &git::Oid, dst: &git::fmt::Qualified, force: bool, patch_id: patch::PatchId, nid: &NodeId, working: &git::raw::Repository, stored: &storage::git::Repository, mut patches: patch::Cache< '_, storage::git::Repository, WriteAs<'_, Signer>, cob::cache::StoreWriter, >, signer: &Signer, opts: Options, expected_refs: &[String], git: &S, ) -> Result, Error> where S: GitService, Signer: crypto::signature::Keypair, Signer: crypto::signature::Signer, Signer: crypto::signature::Signer, Signer: crypto::signature::Verifier, { let Ok(Some(patch)) = patches.get(&patch_id) else { return Err(Error::NotFound(patch_id)); }; let temp = TempPatchRef::new(stored, head, nid, git); temp.push(head, opts.verbosity)?; let base = patch_base(head, &opts, stored)?; // Don't update patch if it already has a matching revision. if patch .revisions() .any(|(_, r)| r.head() == *head && *r.base() == base) { return Ok(None); } let (latest_id, latest) = patch.latest(); let latest = latest.clone(); let message = term::patch::get_update_message(opts.message, &stored.backend, &latest, &head.into())?; let dst = dst.with_namespace(nid.into()); push_ref( head, &dst, force, stored.raw(), opts.verbosity, expected_refs, git, )?; let mut patch_mut = patch::PatchMut::new(patch_id, patch, &mut patches); let revision = patch_mut.update(message, base, *head)?; let Some(revision) = patch_mut.revision(&revision).cloned() else { return Err(Error::RevisionNotFound(revision)); }; eprintln!( "{} Patch {} updated to revision {}", term::PREFIX_SUCCESS, term::format::tertiary(term::format::cob(&patch_id)), term::format::dim(revision.id()) ); // In this case, the patch was already merged via git, and pushed to storage. // To handle this situation, we simply update the patch state to "merged". // // This can happen if for eg. a patch commit is amended, the patch branch is merged // and pushed, but the patch hasn't yet been updated. On push to the patch branch, // it'll seem like the patch is "empty", because the changes are already in the base branch. if base == *head && patch_mut.is_open() { patch_merge(patch_mut, revision.id(), *head, working, signer)?; } else { eprintln!( "To compare against your previous revision {}, run:\n\n {}\n", term::format::tertiary(term::format::cob(&cob::ObjectId::from(git::Oid::from( latest_id )))), patch::RangeDiff::new(&latest, &revision).to_command() ); } Ok(Some(ExplorerResource::Patch { id: patch_id })) } fn push( src: &git::Oid, dst: &git::fmt::Qualified, force: bool, nid: &NodeId, working: &git::raw::Repository, stored: &storage::git::Repository, mut patches: patch::Cache< '_, storage::git::Repository, WriteAs<'_, Signer>, cob::cache::StoreWriter, >, signer: &Signer, verbosity: Verbosity, expected_refs: &[String], git: &S, ) -> Result, Error> where S: GitService, Signer: crypto::signature::Keypair, Signer: crypto::signature::Signer, Signer: crypto::signature::Signer, Signer: crypto::signature::Verifier, { let head = *src; let dst = dst.with_namespace(nid.into()); // 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(); push_ref( src, &dst, force, stored.raw(), verbosity, expected_refs, git, )?; if let Some(old) = old { let proj = stored.project()?; let master = &*git::fmt::Qualified::from(git::fmt::lit::refs_heads(proj.default_branch())); // If we're pushing to the project's default branch, we want to see if any patches got // merged or reverted, and if so, update the patch COB. if &*dst.strip_namespace() == master { let old = old.peel_to_commit()?.id(); // Only delegates affect the merge state of the COB. if stored.delegates()?.contains(&nid.into()) { patch_revert_all(old.into(), head, &stored.backend, &mut patches)?; patch_merge_all(old.into(), head, working, &mut patches, signer)?; } } } Ok(Some(ExplorerResource::Tree { oid: head })) } /// Revert all patches that are no longer included in the base branch. fn patch_revert_all( old: git::Oid, new: git::Oid, stored: &git::raw::Repository, patches: &mut patch::Cache< '_, storage::git::Repository, WriteAs<'_, Signer>, cob::cache::StoreWriter, >, ) -> Result<(), Error> where Signer: crypto::signature::Keypair, Signer: crypto::signature::Signer, { // Find all commits reachable from the old OID but not from the new OID. let mut revwalk = stored.revwalk()?; revwalk.push(old.into())?; revwalk.hide(new.into())?; // List of commits that have been dropped. let dropped = revwalk .map(|r| r.map(git::Oid::from)) .collect::, _>>()?; if dropped.is_empty() { return Ok(()); } // Get the list of merged patches. let merged = patches .merged()? // Skip patches that failed to load. .filter_map(|patch| patch.ok()) .collect::>(); for (id, patch) in merged { let revisions = patch .revisions() .map(|(id, r)| (id, r.head())) .collect::>(); for commit in &dropped { if let Some((revision_id, _)) = revisions.iter().find(|(_, head)| commit == head) { // Simply refreshing the cache entry will pick up on the fact that this patch // is no longer merged in the canonical branch. match patches.write(&id) { Ok(()) => { eprintln!( "{} Patch {} reverted at revision {}", term::PREFIX_WARNING, term::format::tertiary(&id), term::format::dim(term::format::oid(*revision_id)), ); } Err(e) => { eprintln!("{} Error reverting patch {id}: {e}", term::PREFIX_ERROR); } } break; } } } Ok(()) } /// Merge all patches that have been included in the base branch. fn patch_merge_all( old: git::Oid, new: git::Oid, working: &git::raw::Repository, patches: &mut patch::Cache< '_, storage::git::Repository, WriteAs<'_, Signer>, cob::cache::StoreWriter, >, signer: &Signer, ) -> Result<(), Error> where Signer: crypto::signature::Keypair, Signer: crypto::signature::Signer, Signer: crypto::signature::Signer, Signer: crypto::signature::Verifier, { let mut revwalk = working.revwalk()?; revwalk.push_range(&format!("{old}..{new}"))?; // These commits are ordered by children first and then parents. let commits = revwalk .map(|r| r.map(git::Oid::from)) .collect::, _>>()?; if commits.is_empty() { return Ok(()); } let open = patches .opened()? .chain(patches.drafted()?) // Skip patches that failed to load. .filter_map(|patch| patch.ok()) .collect::>(); for (id, patch) in open { // Later revisions are more likely to be merged, so we build the list backwards. let revisions = patch .revisions() .rev() .map(|(id, r)| (id, r.head())) .collect::>(); // Try to find a revision to merge. Favor revisions that match the more recent commits. // It's possible for more than one revision to be merged by this push, so we pick the // revision that is closest to the tip of the commit chain we're pushing. for commit in &commits { if let Some((revision_id, head)) = revisions.iter().find(|(_, head)| commit == head) { let patch = patch::PatchMut::new(id, patch, patches); patch_merge(patch, *revision_id, *head, working, signer)?; break; } } } Ok(()) } fn patch_merge( mut patch: patch::PatchMut<'_, '_, '_, storage::git::Repository, Signer, C>, revision: patch::RevisionId, commit: git::Oid, working: &git::raw::Repository, signer: &Signer, ) -> Result<(), Error> where Signer: crypto::signature::Keypair, Signer: crypto::signature::Signer, Signer: crypto::signature::Signer, Signer: crypto::signature::Verifier, C: cob::cache::Update, { let (latest, _) = patch.latest(); let merged = patch.merge(revision, commit)?; if revision == latest { eprintln!( "{} Patch {} merged", term::PREFIX_SUCCESS, term::format::tertiary(merged.patch) ); } else { eprintln!( "{} Patch {} merged at revision {}", term::PREFIX_SUCCESS, term::format::tertiary(merged.patch), term::format::dim(term::format::oid(revision)), ); } // Delete patch references that were created when the patch was opened. // Note that we don't return an error if we can't delete the refs, since it's // not critical. merged.cleanup(working, signer).ok(); Ok(()) } /// Push a single reference to storage. fn push_ref( src: &git::Oid, dst: &git::fmt::Namespaced, force: bool, stored: &git::raw::Repository, verbosity: Verbosity, expected_refs: &[String], git: &impl GitService, ) -> Result<(), Error> { let path = dunce::canonicalize(stored.path())?.display().to_string(); // Nb. The *force* indicator (`+`) is processed by Git tooling before we even reach this code. // This happens during the `list for-push` phase. let refspec = git::fmt::refspec::Refspec { src, dst, force }; let mut args = vec!["send-pack".to_string()]; let verbosity: git::Verbosity = verbosity.into(); args.extend(verbosity.into_flag()); args.extend([path.to_string(), refspec.to_string()]); for expected in expected_refs { args.push(format!( "--force-with-lease=refs/namespaces/{}/{expected}", dst.namespace() )); } // Rely on the environment variable `GIT_DIR`. let working = None; let output = git.send_pack(working, &args)?; if !output.status.success() { return Err(Error::SendPackFailed { stderr: String::from_utf8_lossy(&output.stderr).to_string(), stdout: String::from_utf8_lossy(&output.stdout).to_string(), status: output.status, }); } Ok(()) }