Skip to main content

sley_remote/
clone.rs

1//! Callable clone orchestration for HTTP(S) and local (`file://`/path) remotes.
2//!
3//! [`clone`] performs the transport-shaped core of `git clone` for the common
4//! branch-tracking case: it initializes the destination repository, fetches from
5//! the resolved remote (reusing the Stage E [`crate::fetch`] machinery), creates
6//! the local branch at the fetched remote tip, points `refs/remotes/<origin>/HEAD`
7//! at the remote default branch, and checks out the worktree (via
8//! [`sley_worktree`]). Everything is taken as explicit parameters — the
9//! destination, the [`ObjectFormat`], the resolved [`CloneSource`], a
10//! [`CloneOptions`], two caller callbacks, and the seam objects
11//! ([`CredentialProvider`], [`ProgressSink`]) — so it never reads process-global
12//! state, mutates the process CWD, parses arguments, or prints.
13//!
14//! Crucially, [`clone`] takes the destination `git_dir` implicitly (from the
15//! init it performs) and drives the fetch against it directly, so there is no
16//! `set_current_dir` dance: the CLI's old clone path chdir'd into the new repo so
17//! its `discover_git_dir`/`ls_remote_resolved_url` helpers would resolve the
18//! freshly-created repository, then restored the CWD. Here the repository and
19//! remote are already resolved by the caller and passed in, so the process CWD is
20//! never touched.
21//!
22//! The CLI keeps everything that is policy or presentation: argument parsing, the
23//! "Cloning into…"/"done." lines and `--depth`/`--filter` warnings, the
24//! unsupported-option gating (bare/mirror, `--revision`, `--shared`/`--reference`,
25//! `--bundle-uri`, SHA-256 over HTTP), and the post-checkout steps
26//! (`--no-checkout` worktree removal, `--sparse`, `--separate-git-dir`). The two
27//! `configure` callbacks let the CLI run its own config-writing helpers (template
28//! application, `remote.<origin>.*`, `-c` overrides, `submodule.active`, branch
29//! upstream) at the right points in the flow while returning the [`GitConfig`]
30//! the next step needs, keeping that CLI-coupled config I/O out of the library.
31//!
32//! SSH clone uses the same [`crate::fetch`] SSH dispatch as fetch; only the
33//! caller-side URL resolution and post-clone presentation stay in the CLI.
34
35use std::path::{Path, PathBuf};
36
37use sley_config::GitConfig;
38use sley_core::{GitError, ObjectFormat, ObjectId, Result};
39use sley_formats::RepositoryLayout;
40use sley_refs::{FileRefStore, RefTarget, RefUpdate};
41use sley_transport::RemoteUrl;
42
43use crate::fetch::{FetchOptions, FetchSource, fetch};
44use crate::{CredentialProvider, ProgressSink};
45
46/// The unborn placeholder branch the destination is initialized on, replaced by
47/// the real checked-out branch; mirrors the CLI's previous clone init.
48const CLONE_UNBORN_BRANCH: &str = "__git_rs_clone_unborn__";
49
50/// How [`clone`] reaches the remote it is cloning from.
51///
52/// The caller resolves the remote (URL rewriting, repository discovery — all
53/// process-state dependent) and hands `clone` a concrete transport.
54pub enum CloneSource {
55    /// A smart-HTTP(S) remote at the given already-resolved URL.
56    Http(RemoteUrl),
57    /// An SSH remote at the given already-resolved URL. Fetched by spawning `ssh`
58    /// (the credential seam is unused — the `ssh` program owns authentication).
59    Ssh(RemoteUrl),
60    /// A native anonymous `git://` remote at the given already-resolved URL.
61    Git(RemoteUrl),
62    /// A local repository served in-process from `git_dir`.
63    Local {
64        /// The remote repository's `$GIT_DIR`.
65        git_dir: PathBuf,
66        /// The remote repository's common `$GIT_DIR` (object format source).
67        common_git_dir: PathBuf,
68    },
69}
70
71/// The clone inputs the library needs for the branch-tracking flow, all resolved
72/// by the caller. The remaining `git clone` knobs (bare/mirror, `--revision`,
73/// templates, config overrides, sparse, separate-git-dir, etc.) stay in the CLI:
74/// the unsupported ones are gated before `clone` is called, and the config-writing
75/// ones run inside the `configure`/`configure_branch` callbacks.
76pub struct CloneOptions<'a> {
77    /// The remote name to configure and track (`--origin`, default `origin`).
78    pub origin: &'a str,
79    /// The branch to create locally and check out (the requested `--branch` or
80    /// the remote's default branch).
81    pub checkout_branch: &'a str,
82    /// The remote's default branch, used to decide whether to point
83    /// `refs/remotes/<origin>/HEAD` at it.
84    pub remote_head_branch: &'a str,
85    /// Whether only `checkout_branch` was fetched (`--single-branch`); when set,
86    /// `refs/remotes/<origin>/HEAD` is only written if the checked-out branch is
87    /// the remote default.
88    pub single_branch: bool,
89    /// Shallow clone depth (`--depth N`): truncate history to `N` commits per tip,
90    /// writing `$GIT_DIR/shallow`. `None` is a full clone. Honored by the HTTP
91    /// and SSH transports and by the in-process local server (`git clone
92    /// --no-local --depth N <path>`); a depth on a plain local clone is
93    /// warned-and-ignored upstream of `clone` by the caller, matching git's
94    /// `is_local` behavior.
95    pub depth: Option<u32>,
96    /// `--shallow-since=<date>` (parsed to an epoch): deepen to commits newer
97    /// than the date. Local in-process transport only.
98    pub deepen_since: Option<i64>,
99    /// `--shallow-exclude=<ref>` values, resolved against the remote.
100    pub deepen_not: Vec<String>,
101    /// The committer identity for the branch-creation and checkout reflog entries.
102    pub committer: Vec<u8>,
103    /// The remote `HEAD` is detached at this commit (no default branch). After
104    /// the fetch the destination checks out this commit detached instead of
105    /// creating `checkout_branch`; `refs/remotes/<origin>/HEAD` is not written.
106    pub detached_head: Option<ObjectId>,
107    /// Partial-clone object filter (`--filter=blob:none`) to apply to the
108    /// clone fetch. Only honored by the in-process local server.
109    pub filter: Option<sley_odb::PackObjectFilter>,
110    /// Whether `checkout_branch` came from an explicit `--branch`. When set, a
111    /// missing remote tip for that branch is a hard error ("Remote branch … not
112    /// found"); when unset, a missing tip is an empty/unborn-repository clone.
113    pub branch_explicit: bool,
114}
115
116/// The structured result of a [`clone`].
117#[derive(Debug, Clone)]
118pub struct CloneOutcome {
119    /// The destination repository's `$GIT_DIR` (the `.git` directory created by
120    /// the init step). The caller uses it for its post-checkout steps.
121    pub git_dir: PathBuf,
122    /// The object id the local branch was created at (the fetched remote tip),
123    /// or `None` when the remote was empty/unborn (no branch was created and
124    /// `HEAD` was left as an unborn symref to `checkout_branch`).
125    pub branch_oid: Option<ObjectId>,
126    /// True when the remote advertised no refs for `checkout_branch` and no
127    /// `--branch`/`--revision` was requested: an empty/unborn-repository clone.
128    /// The caller prints git's "You appear to have cloned an empty repository."
129    /// warning and skips the worktree checkout.
130    pub empty: bool,
131}
132
133/// Fully resolved inputs for a [`clone`] run.
134pub struct CloneRequest<'a> {
135    /// Destination worktree/repository path.
136    pub destination: &'a Path,
137    /// Destination repository object format.
138    pub format: ObjectFormat,
139    /// Already-resolved clone source.
140    pub source: &'a CloneSource,
141    /// Clone behavior and branch-tracking options.
142    pub options: &'a CloneOptions<'a>,
143}
144
145/// Mutable seams used while cloning.
146pub struct CloneServices<'a> {
147    /// Callback that writes initial repository config and returns the resulting
148    /// config snapshot used for the fetch.
149    pub configure: &'a mut dyn FnMut(&Path) -> Result<GitConfig>,
150    /// Callback that writes local branch upstream config and returns the config
151    /// snapshot used for checkout filtering.
152    pub configure_branch: &'a mut dyn FnMut(&Path, &str) -> Result<GitConfig>,
153    /// Credential source for authenticated transports.
154    pub credentials: &'a mut dyn CredentialProvider,
155    /// Progress sink for fetch progress/prune notices.
156    pub progress: &'a mut dyn ProgressSink,
157}
158
159/// Clone the resolved `source` into a fresh repository at `destination`.
160///
161/// Performs the transport-shaped core the CLI's `clone_http_repository` and the
162/// inline local clone path shared: initializes the repository, invokes
163/// `configure` to let the caller write the new repo's config (returning the
164/// [`GitConfig`] to fetch against), fetches the configured refs (reusing
165/// [`crate::fetch::fetch`] with clone's fixed options), creates the local
166/// `checkout_branch` at its fetched remote tip, invokes `configure_branch` to let
167/// the caller write the branch's upstream config (returning the [`GitConfig`] to
168/// check out against), points `refs/remotes/<origin>/HEAD` at the remote default
169/// branch when appropriate, and checks out the worktree.
170///
171/// `configure` runs right after init (before the fetch) and must return the
172/// repository config; `configure_branch` runs right after the local branch is
173/// created (before the worktree checkout) and must return the config used for
174/// checkout. Splitting the config writes into these callbacks keeps the CLI's
175/// config I/O helpers (which depend on CLI-specific config serialization and
176/// templates) out of the library while preserving their ordering in the flow.
177///
178/// Emits any library-side progress through `progress` and returns the structured
179/// [`CloneOutcome`]; never prints, mutates the process CWD, or returns
180/// `GitError::Exit`. A missing `refs/remotes/<origin>/<checkout_branch>` after the
181/// fetch is reported as [`GitError::NotFound`] for the caller to map (the CLI
182/// turns an explicit `--branch` miss into its own message).
183pub fn clone(request: CloneRequest<'_>, services: CloneServices<'_>) -> Result<CloneOutcome> {
184    let layout = RepositoryLayout::init_at_with_initial_branch(
185        request.destination,
186        request.format,
187        false,
188        CLONE_UNBORN_BRANCH,
189    )?;
190    let git_dir = layout.git_dir;
191
192    let config = (services.configure)(&git_dir)?;
193    let fetch_source = match request.source {
194        #[cfg(feature = "http")]
195        CloneSource::Http(remote) => FetchSource::Http(remote.clone()),
196        #[cfg(not(feature = "http"))]
197        CloneSource::Http(_) => {
198            return Err(GitError::Unsupported(
199                "HTTP transport is not enabled in this build".into(),
200            ));
201        }
202        CloneSource::Ssh(remote) => FetchSource::Ssh(remote.clone()),
203        CloneSource::Git(remote) => FetchSource::Git(remote.clone()),
204        CloneSource::Local {
205            git_dir: remote_git_dir,
206            common_git_dir: remote_common_git_dir,
207        } => FetchSource::Local {
208            git_dir: remote_git_dir.clone(),
209            common_git_dir: remote_common_git_dir.clone(),
210        },
211    };
212    let fetch_options = clone_fetch_options(
213        request.options.depth,
214        request.options.deepen_since,
215        request.options.deepen_not.clone(),
216        request.options.filter,
217    );
218    fetch(
219        crate::fetch::FetchRequest {
220            git_dir: &git_dir,
221            format: request.format,
222            config: &config,
223            remote_name: request.options.origin,
224            source: &fetch_source,
225            refspecs: &[],
226            options: &fetch_options,
227        },
228        crate::fetch::FetchServices {
229            credentials: services.credentials,
230            progress: services.progress,
231        },
232    )?;
233
234    let store = FileRefStore::new(&git_dir, request.format);
235    if let Some(detached) = &request.options.detached_head {
236        sley_worktree::checkout_detached_filtered(
237            request.destination,
238            &git_dir,
239            request.format,
240            detached,
241            request.options.committer.clone(),
242            b"clone: checkout".to_vec(),
243            &config,
244        )?;
245        return Ok(CloneOutcome {
246            git_dir,
247            branch_oid: Some(*detached),
248            empty: false,
249        });
250    }
251    let remote_branch_ref = format!(
252        "refs/remotes/{}/{}",
253        request.options.origin, request.options.checkout_branch
254    );
255    let branch_oid = match store.read_ref(&remote_branch_ref)? {
256        Some(RefTarget::Direct(oid)) => oid,
257        Some(RefTarget::Symbolic(_)) => {
258            return Err(GitError::Unsupported(
259                "clone remote-tracking branch must be direct".into(),
260            ));
261        }
262        None => {
263            // The remote advertised no tip for the branch we are tracking. When
264            // the caller did not request an explicit branch this is an
265            // empty/unborn-repository clone: upstream `builtin/clone.c` warns,
266            // skips the checkout, and leaves `HEAD` as an unborn symref pointing
267            // at the remote's (or local default) branch — `update_head`'s
268            // `unborn` arm. We mirror that by setting `HEAD` and returning a
269            // marker for the CLI to print the warning. An explicit-branch miss
270            // is still a hard error (the CLI maps it to git's "Remote branch …
271            // not found" message).
272            if request.options.branch_explicit {
273                return Err(GitError::reference_not_found(format!(
274                    "remote ref {remote_branch_ref}"
275                )));
276            }
277            let unborn = format!("refs/heads/{}", request.options.checkout_branch);
278            let mut tx = store.transaction();
279            tx.update(RefUpdate {
280                name: "HEAD".to_string(),
281                expected: None,
282                new: RefTarget::Symbolic(unborn),
283                reflog: None,
284            });
285            tx.commit()?;
286            // Install branch upstream config for the unborn branch, matching
287            // git's `install_branch_config` in the unborn path.
288            (services.configure_branch)(&git_dir, request.options.checkout_branch)?;
289            return Ok(CloneOutcome {
290                git_dir,
291                branch_oid: None,
292                empty: true,
293            });
294        }
295    };
296    store.create_branch(
297        request.options.checkout_branch,
298        branch_oid.clone(),
299        request.options.committer.clone(),
300        format!(
301            "branch: Created from {}/{}",
302            request.options.origin, request.options.checkout_branch
303        )
304        .into_bytes(),
305    )?;
306    // The branch upstream config is written here and the resulting config is used
307    // for the checkout below, matching the CLI's previous order: configure the
308    // branch, point the remote `HEAD`, then read the (now final) config for the
309    // smudge-side checkout filters. Pointing `HEAD` only updates refs, so it does
310    // not change the config `configure_branch` returns.
311    let checkout_config = (services.configure_branch)(&git_dir, request.options.checkout_branch)?;
312    if !request.options.single_branch
313        || request.options.checkout_branch == request.options.remote_head_branch
314    {
315        let mut tx = store.transaction();
316        tx.update(RefUpdate {
317            name: format!("refs/remotes/{}/HEAD", request.options.origin),
318            expected: None,
319            new: RefTarget::Symbolic(format!(
320                "refs/remotes/{}/{}",
321                request.options.origin, request.options.remote_head_branch
322            )),
323            reflog: None,
324        });
325        tx.commit()?;
326    }
327
328    sley_worktree::checkout_branch_filtered(
329        request.destination,
330        &git_dir,
331        request.format,
332        request.options.checkout_branch,
333        request.options.committer.clone(),
334        &checkout_config,
335    )?;
336
337    Ok(CloneOutcome {
338        git_dir,
339        branch_oid: Some(branch_oid),
340        empty: false,
341    })
342}
343
344/// The fixed [`FetchOptions`] a clone fetch uses: quiet, auto-follow tags, write
345/// `FETCH_HEAD`, the requested shallow `depth`, and otherwise neutral (no prune, no
346/// `--tags`, not a dry run, not appending). Mirrors the options the CLI's clone
347/// paths passed.
348fn clone_fetch_options(
349    depth: Option<u32>,
350    deepen_since: Option<i64>,
351    deepen_not: Vec<String>,
352    filter: Option<sley_odb::PackObjectFilter>,
353) -> FetchOptions {
354    FetchOptions {
355        quiet: true,
356        auto_follow_tags: true,
357        fetch_all_tags: false,
358        prune: false,
359        dry_run: false,
360        append: false,
361        write_fetch_head: true,
362        tag_option_explicit: false,
363        prune_option_explicit: false,
364        depth,
365        merge_srcs: Vec::new(),
366        filter,
367        cloning: true,
368        update_shallow: false,
369        deepen_relative: false,
370        deepen_since,
371        deepen_not,
372    }
373}