rtb-vcs 0.5.2

Rust Tool Base — release-provider abstractions and backends (GitHub, GitLab, Bitbucket, Gitea, Codeberg, Direct).
Documentation
//! `Repo::clone` — clone a remote repository.
//!
//! v0.5 commits 4 + 5b.
//!
//! - **Anonymous (commit 4):** uses `gix::prepare_clone` →
//!   `fetch_then_checkout` → `main_worktree` → `persist`.
//! - **Authenticated (commit 5b):** shells out to `git clone -c
//!   credential.helper=...` with the token in process env. The
//!   choice of backend by auth-presence is internal — A8 wrap-not-
//!   leak — and may unify later.
//!
//! Both paths run inside `tokio::task::spawn_blocking` so callers
//! stay async.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::AtomicBool;

use rtb_credentials::CredentialRef;

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

/// Options for [`Repo::clone`].
///
/// `#[non_exhaustive]` — construct via [`CloneOptions::default()`]
/// and the builder methods.
#[derive(Debug, Default, Clone)]
#[non_exhaustive]
pub struct CloneOptions {
    /// Credential reference used to authenticate the clone (HTTPS
    /// only). `None` for anonymous clones. Resolution goes through
    /// `rtb_credentials::Resolver::with_platform_default()`.
    pub credential: Option<CredentialRef>,
}

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

impl Repo {
    /// Clone `url` into `dst`.
    ///
    /// Anonymous clones (no credential on `opts`) run through gix
    /// directly. Authenticated clones shell out to `git` with the
    /// resolved token in process env (never argv).
    ///
    /// `dst` must not exist or must be an empty directory.
    ///
    /// # Errors
    ///
    /// - [`RepoError::CloneFailed`] — gix or `git` could not fetch
    ///   refs, download objects, or check out the working tree.
    /// - [`RepoError::Auth`] — `opts.credential` could not be
    ///   resolved.
    pub async fn clone(
        url: &str,
        dst: impl AsRef<Path>,
        opts: CloneOptions,
    ) -> Result<Self, RepoError> {
        let url = url.to_string();
        let dst: PathBuf = dst.as_ref().to_path_buf();

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

        let dst_for_task = dst.clone();
        let url_for_task = url.clone();
        tokio::task::spawn_blocking(move || {
            token.map_or_else(
                || run_clone_anonymous(&url_for_task, &dst_for_task),
                |secret| run_clone_with_auth(&url_for_task, &dst_for_task, &auth::expose(&secret)),
            )
        })
        .await
        .map_err(|join| RepoError::CloneFailed {
            url: url.clone(),
            cause: format!("spawn_blocking join: {join}"),
        })?
    }
}

fn run_clone_anonymous(url: &str, dst: &Path) -> Result<Repo, RepoError> {
    let mut prepare = gix::prepare_clone(url, dst).map_err(|e| RepoError::CloneFailed {
        url: url.to_string(),
        cause: format!("prepare: {e}"),
    })?;

    let interrupt = AtomicBool::new(false);
    let (mut prepare_checkout, _fetch_outcome) =
        prepare.fetch_then_checkout(gix::progress::Discard, &interrupt).map_err(|e| {
            RepoError::CloneFailed { url: url.to_string(), cause: format!("fetch: {e}") }
        })?;

    let (repository, _checkout_outcome) =
        prepare_checkout.main_worktree(gix::progress::Discard, &interrupt).map_err(|e| {
            RepoError::CloneFailed { url: url.to_string(), cause: format!("checkout: {e}") }
        })?;

    Ok(Repo::from_thread_safe(repository.into_sync(), dst.to_path_buf()))
}

fn run_clone_with_auth(url: &str, dst: &Path, token: &str) -> Result<Repo, RepoError> {
    let out = Command::new("git")
        .arg("-c")
        .arg(format!("credential.helper={}", auth::CREDENTIAL_HELPER))
        .arg("clone")
        .arg("--")
        .arg(url)
        .arg(dst)
        .env(auth::TOKEN_ENV, token)
        // Prevent interactive prompts on auth failure — return non-
        // zero immediately so the caller gets a CloneFailed.
        .env("GIT_TERMINAL_PROMPT", "0")
        .output()
        .map_err(|e| RepoError::CloneFailed {
            url: url.to_string(),
            cause: format!("spawn `git clone`: {e}"),
        })?;
    if !out.status.success() {
        return Err(RepoError::CloneFailed {
            url: url.to_string(),
            cause: format!("git clone: {}", String::from_utf8_lossy(&out.stderr).trim()),
        });
    }
    // Open the freshly-cloned repo to return a `Repo` handle.
    let repo = gix::open(dst).map_err(|e| RepoError::CloneFailed {
        url: url.to_string(),
        cause: format!("open post-clone: {e}"),
    })?;
    Ok(Repo::from_thread_safe(repo.into_sync(), dst.to_path_buf()))
}