rtb-vcs 0.5.2

Rust Tool Base — release-provider abstractions and backends (GitHub, GitLab, Bitbucket, Gitea, Codeberg, Direct).
Documentation
//! `Repo::push` — push refs to a remote.
//!
//! v0.5 commit 7. Anonymous and authenticated pushes share a single
//! shell-out path mirroring [`super::fetch`]: the auth case adds
//! `-c credential.helper=...` and an env var; the anonymous case
//! omits both.
//!
//! Spec A4 (the `git2-fallback` opt-in) is obsolete now that all
//! v0.5 write paths shell out to `git`. The `RepoError::PushUnsupported`
//! variant stays for backwards compatibility (the enum is
//! `#[non_exhaustive]`) but is no longer produced.

use std::path::Path;
use std::process::Command;

use rtb_credentials::CredentialRef;

use super::{auth, Repo, RepoError};

/// Options for [`Repo::push`].
#[derive(Debug, Default, Clone)]
#[non_exhaustive]
pub struct PushOptions {
    /// Credential reference used to authenticate the push (HTTPS
    /// only). `None` for anonymous pushes.
    pub credential: Option<CredentialRef>,
}

impl PushOptions {
    /// Attach a credential for HTTPS authentication. The reference
    /// is resolved at push time via `rtb-credentials`.
    #[must_use]
    pub fn with_credential(mut self, cref: CredentialRef) -> Self {
        self.credential = Some(cref);
        self
    }
}

impl Repo {
    /// Push `refspec` to `remote`.
    ///
    /// # Errors
    ///
    /// - [`RepoError::PushFailed`] — `git push` returned non-zero
    ///   (unknown remote, network failure, non-fast-forward).
    /// - [`RepoError::Auth`] — `opts.credential` could not be
    ///   resolved.
    pub async fn push(
        &self,
        remote: &str,
        refspec: &str,
        opts: PushOptions,
    ) -> Result<(), RepoError> {
        let workdir = self.path().to_path_buf();
        let remote = remote.to_string();
        let refspec = refspec.to_string();

        let token = match opts.credential.as_ref() {
            Some(cref) => Some(auth::resolve_default(cref).await?),
            None => None,
        };

        tokio::task::spawn_blocking(move || {
            let token_str = token.as_ref().map(auth::expose);
            run_push(&workdir, &remote, &refspec, token_str.as_deref())
        })
        .await
        .map_err(|join| RepoError::PushFailed {
            remote: String::new(),
            refspec: String::new(),
            cause: format!("spawn_blocking join: {join}"),
        })?
    }
}

fn run_push(
    workdir: &Path,
    remote: &str,
    refspec: &str,
    token: Option<&str>,
) -> Result<(), RepoError> {
    let mut cmd = Command::new("git");
    if token.is_some() {
        cmd.arg("-c").arg(format!("credential.helper={}", auth::CREDENTIAL_HELPER));
    }
    cmd.arg("push").arg(remote).arg(refspec).current_dir(workdir).env("GIT_TERMINAL_PROMPT", "0");
    if let Some(t) = token {
        cmd.env(auth::TOKEN_ENV, t);
    }
    let out = cmd.output().map_err(|e| RepoError::PushFailed {
        remote: remote.to_string(),
        refspec: refspec.to_string(),
        cause: format!("spawn `git push`: {e}"),
    })?;
    if !out.status.success() {
        return Err(RepoError::PushFailed {
            remote: remote.to_string(),
            refspec: refspec.to_string(),
            cause: format!("git push: {}", String::from_utf8_lossy(&out.stderr).trim()),
        });
    }
    Ok(())
}