Skip to main content

rtb_vcs/git/
clone.rs

1//! `Repo::clone` — clone a remote repository.
2//!
3//! v0.5 commits 4 + 5b.
4//!
5//! - **Anonymous (commit 4):** uses `gix::prepare_clone` →
6//!   `fetch_then_checkout` → `main_worktree` → `persist`.
7//! - **Authenticated (commit 5b):** shells out to `git clone -c
8//!   credential.helper=...` with the token in process env. The
9//!   choice of backend by auth-presence is internal — A8 wrap-not-
10//!   leak — and may unify later.
11//!
12//! Both paths run inside `tokio::task::spawn_blocking` so callers
13//! stay async.
14
15use std::path::{Path, PathBuf};
16use std::process::Command;
17use std::sync::atomic::AtomicBool;
18
19use rtb_credentials::CredentialRef;
20
21use super::{auth, Repo, RepoError};
22
23/// Options for [`Repo::clone`].
24///
25/// `#[non_exhaustive]` — construct via [`CloneOptions::default()`]
26/// and the builder methods.
27#[derive(Debug, Default, Clone)]
28#[non_exhaustive]
29pub struct CloneOptions {
30    /// Credential reference used to authenticate the clone (HTTPS
31    /// only). `None` for anonymous clones. Resolution goes through
32    /// `rtb_credentials::Resolver::with_platform_default()`.
33    pub credential: Option<CredentialRef>,
34}
35
36impl CloneOptions {
37    /// Attach a credential for HTTPS authentication. The reference
38    /// is resolved at clone time via `rtb-credentials` (env →
39    /// keychain → literal → fallback-env).
40    #[must_use]
41    pub fn with_credential(mut self, cref: CredentialRef) -> Self {
42        self.credential = Some(cref);
43        self
44    }
45}
46
47impl Repo {
48    /// Clone `url` into `dst`.
49    ///
50    /// Anonymous clones (no credential on `opts`) run through gix
51    /// directly. Authenticated clones shell out to `git` with the
52    /// resolved token in process env (never argv).
53    ///
54    /// `dst` must not exist or must be an empty directory.
55    ///
56    /// # Errors
57    ///
58    /// - [`RepoError::CloneFailed`] — gix or `git` could not fetch
59    ///   refs, download objects, or check out the working tree.
60    /// - [`RepoError::Auth`] — `opts.credential` could not be
61    ///   resolved.
62    pub async fn clone(
63        url: &str,
64        dst: impl AsRef<Path>,
65        opts: CloneOptions,
66    ) -> Result<Self, RepoError> {
67        let url = url.to_string();
68        let dst: PathBuf = dst.as_ref().to_path_buf();
69
70        let token = match opts.credential.as_ref() {
71            Some(cref) => Some(auth::resolve_default(cref).await?),
72            None => None,
73        };
74
75        let dst_for_task = dst.clone();
76        let url_for_task = url.clone();
77        tokio::task::spawn_blocking(move || {
78            token.map_or_else(
79                || run_clone_anonymous(&url_for_task, &dst_for_task),
80                |secret| run_clone_with_auth(&url_for_task, &dst_for_task, &auth::expose(&secret)),
81            )
82        })
83        .await
84        .map_err(|join| RepoError::CloneFailed {
85            url: url.clone(),
86            cause: format!("spawn_blocking join: {join}"),
87        })?
88    }
89}
90
91fn run_clone_anonymous(url: &str, dst: &Path) -> Result<Repo, RepoError> {
92    let mut prepare = gix::prepare_clone(url, dst).map_err(|e| RepoError::CloneFailed {
93        url: url.to_string(),
94        cause: format!("prepare: {e}"),
95    })?;
96
97    let interrupt = AtomicBool::new(false);
98    let (mut prepare_checkout, _fetch_outcome) =
99        prepare.fetch_then_checkout(gix::progress::Discard, &interrupt).map_err(|e| {
100            RepoError::CloneFailed { url: url.to_string(), cause: format!("fetch: {e}") }
101        })?;
102
103    let (repository, _checkout_outcome) =
104        prepare_checkout.main_worktree(gix::progress::Discard, &interrupt).map_err(|e| {
105            RepoError::CloneFailed { url: url.to_string(), cause: format!("checkout: {e}") }
106        })?;
107
108    Ok(Repo::from_thread_safe(repository.into_sync(), dst.to_path_buf()))
109}
110
111fn run_clone_with_auth(url: &str, dst: &Path, token: &str) -> Result<Repo, RepoError> {
112    let out = Command::new("git")
113        .arg("-c")
114        .arg(format!("credential.helper={}", auth::CREDENTIAL_HELPER))
115        .arg("clone")
116        .arg("--")
117        .arg(url)
118        .arg(dst)
119        .env(auth::TOKEN_ENV, token)
120        // Prevent interactive prompts on auth failure — return non-
121        // zero immediately so the caller gets a CloneFailed.
122        .env("GIT_TERMINAL_PROMPT", "0")
123        .output()
124        .map_err(|e| RepoError::CloneFailed {
125            url: url.to_string(),
126            cause: format!("spawn `git clone`: {e}"),
127        })?;
128    if !out.status.success() {
129        return Err(RepoError::CloneFailed {
130            url: url.to_string(),
131            cause: format!("git clone: {}", String::from_utf8_lossy(&out.stderr).trim()),
132        });
133    }
134    // Open the freshly-cloned repo to return a `Repo` handle.
135    let repo = gix::open(dst).map_err(|e| RepoError::CloneFailed {
136        url: url.to_string(),
137        cause: format!("open post-clone: {e}"),
138    })?;
139    Ok(Repo::from_thread_safe(repo.into_sync(), dst.to_path_buf()))
140}