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::{InitOptions, RefStorageFormat, RepositoryBootstrap};
40use sley_object::{Commit, ObjectType, Tree};
41use sley_odb::{FileObjectDatabase, ObjectReader};
42use sley_refs::{FileRefStore, RefTarget, RefUpdate};
43use sley_transport::RemoteUrl;
44#[cfg(feature = "http")]
45use sley_transport::{HttpClient, UreqHttpClient};
46
47use crate::fetch::{FetchOptions, FetchSource};
48#[cfg(not(feature = "http"))]
49use crate::fetch::fetch;
50use crate::{CredentialProvider, ProgressSink};
51
52/// Internal placeholder branch used while clone initializes before it knows
53/// which branch, detached commit, or unborn remote state will own `HEAD`.
54const CLONE_UNBORN_BRANCH: &str = "__sley_clone_unborn__";
55
56/// How [`clone`] reaches the remote it is cloning from.
57///
58/// The caller resolves the remote (URL rewriting, repository discovery — all
59/// process-state dependent) and hands `clone` a concrete transport.
60pub enum CloneSource {
61    /// A smart-HTTP(S) remote at the given already-resolved URL.
62    Http(RemoteUrl),
63    /// An SSH remote at the given already-resolved URL. Fetched by spawning `ssh`
64    /// (the credential seam is unused — the `ssh` program owns authentication).
65    Ssh(RemoteUrl),
66    /// A native anonymous `git://` remote at the given already-resolved URL.
67    Git {
68        remote: RemoteUrl,
69        protocol_v2: bool,
70    },
71    /// A local repository served in-process from `git_dir`.
72    Local {
73        /// The remote repository's `$GIT_DIR`.
74        git_dir: PathBuf,
75        /// The remote repository's common `$GIT_DIR` (object format source).
76        common_git_dir: PathBuf,
77    },
78}
79
80/// The clone inputs the library needs for the branch-tracking flow, all resolved
81/// by the caller. The remaining `git clone` knobs (bare/mirror, `--revision`,
82/// templates, config overrides, sparse, separate-git-dir, etc.) stay in the CLI:
83/// the unsupported ones are gated before `clone` is called, and the config-writing
84/// ones run inside the `configure`/`configure_branch` callbacks.
85pub struct CloneOptions<'a> {
86    /// The remote name to configure and track (`--origin`, default `origin`).
87    pub origin: &'a str,
88    /// The branch to create locally and check out (the requested `--branch` or
89    /// the remote's default branch).
90    pub checkout_branch: &'a str,
91    /// The remote's default branch, used to decide whether to point
92    /// `refs/remotes/<origin>/HEAD` at it.
93    pub remote_head_branch: &'a str,
94    /// Whether only `checkout_branch` was fetched (`--single-branch`); when set,
95    /// `refs/remotes/<origin>/HEAD` is only written if the checked-out branch is
96    /// the remote default.
97    pub single_branch: bool,
98    /// Shallow clone depth (`--depth N`): truncate history to `N` commits per tip,
99    /// writing `$GIT_DIR/shallow`. `None` is a full clone. Honored by the HTTP
100    /// and SSH transports and by the in-process local server (`git clone
101    /// --no-local --depth N <path>`); a depth on a plain local clone is
102    /// warned-and-ignored upstream of `clone` by the caller, matching git's
103    /// `is_local` behavior.
104    pub depth: Option<u32>,
105    /// `--shallow-since=<date>` (parsed to an epoch): deepen to commits newer
106    /// than the date. Local in-process transport only.
107    pub deepen_since: Option<i64>,
108    /// `--shallow-exclude=<ref>` values, resolved against the remote.
109    pub deepen_not: Vec<String>,
110    /// The committer identity for the branch-creation and checkout reflog entries.
111    pub committer: Vec<u8>,
112    /// The remote `HEAD` is detached at this commit (no default branch). After
113    /// the fetch the destination checks out this commit detached instead of
114    /// creating `checkout_branch`; `refs/remotes/<origin>/HEAD` is not written.
115    pub detached_head: Option<ObjectId>,
116    /// Whether clone should populate the worktree. `--no-checkout` still writes
117    /// refs/config but must not hydrate filtered blobs solely for checkout.
118    pub checkout: bool,
119    /// Partial-clone object filter (`--filter=blob:none`) to apply to the
120    /// clone fetch. Only honored by the in-process local server.
121    pub filter: Option<sley_odb::PackObjectFilter>,
122    /// Whether `checkout_branch` came from an explicit `--branch`. When set, a
123    /// missing remote tip for that branch is a hard error ("Remote branch … not
124    /// found"); when unset, a missing tip is an empty/unborn-repository clone.
125    pub branch_explicit: bool,
126    /// Destination repository ref storage format.
127    pub ref_storage: RefStorageFormat,
128    /// SSH command-line shape for the clone's internal fetch, used for
129    /// clone-only flags like `-4`/`-6`.
130    pub ssh_options: Option<crate::ssh::SshTransportOptions>,
131    /// Refuse cloning from a shallow source (`--reject-shallow`).
132    pub reject_shallow: bool,
133}
134
135/// The structured result of a [`clone`].
136#[derive(Debug, Clone)]
137pub struct CloneOutcome {
138    /// The destination repository's `$GIT_DIR` (the `.git` directory created by
139    /// the init step). The caller uses it for its post-checkout steps.
140    pub git_dir: PathBuf,
141    /// The object id the local branch was created at (the fetched remote tip),
142    /// or `None` when the remote was empty/unborn (no branch was created and
143    /// `HEAD` was left as an unborn symref to `checkout_branch`).
144    pub branch_oid: Option<ObjectId>,
145    /// True when the remote advertised no refs for `checkout_branch` and no
146    /// `--branch`/`--revision` was requested: an empty/unborn-repository clone.
147    /// The caller prints git's "You appear to have cloned an empty repository."
148    /// warning and skips the worktree checkout.
149    pub empty: bool,
150}
151
152/// Fully resolved inputs for a [`clone`] run.
153pub struct CloneRequest<'a> {
154    /// Destination worktree/repository path.
155    pub destination: &'a Path,
156    /// Explicit destination git directory, used by `GIT_WORK_TREE git clone`
157    /// where the command-line directory is the repository admin dir and the
158    /// worktree lives elsewhere.
159    pub git_dir_override: Option<&'a Path>,
160    /// Value to write as `core.worktree` when `git_dir_override` separates the
161    /// admin dir from the checkout root.
162    pub core_worktree: Option<&'a str>,
163    /// Destination repository object format.
164    pub format: ObjectFormat,
165    /// Already-resolved clone source.
166    pub source: &'a CloneSource,
167    /// Clone behavior and branch-tracking options.
168    pub options: &'a CloneOptions<'a>,
169}
170
171/// Mutable seams used while cloning.
172pub struct CloneServices<'a> {
173    /// Callback that writes initial repository config and returns the resulting
174    /// config snapshot used for the fetch.
175    pub configure: &'a mut dyn FnMut(&Path) -> Result<GitConfig>,
176    /// Callback that writes local branch upstream config and returns the config
177    /// snapshot used for checkout filtering.
178    pub configure_branch: &'a mut dyn FnMut(&Path, &str) -> Result<GitConfig>,
179    /// Credential source for authenticated transports.
180    pub credentials: &'a mut dyn CredentialProvider,
181    /// Progress sink for fetch progress/prune notices.
182    pub progress: &'a mut dyn ProgressSink,
183}
184
185/// Clone the resolved `source` into a fresh repository at `destination`.
186///
187/// Performs the transport-shaped core the CLI's `clone_http_repository` and the
188/// inline local clone path shared: initializes the repository, invokes
189/// `configure` to let the caller write the new repo's config (returning the
190/// [`GitConfig`] to fetch against), fetches the configured refs (reusing
191/// [`crate::fetch::fetch`] with clone's fixed options), creates the local
192/// `checkout_branch` at its fetched remote tip, invokes `configure_branch` to let
193/// the caller write the branch's upstream config (returning the [`GitConfig`] to
194/// check out against), points `refs/remotes/<origin>/HEAD` at the remote default
195/// branch when appropriate, and checks out the worktree.
196///
197/// `configure` runs right after init (before the fetch) and must return the
198/// repository config; `configure_branch` runs right after the local branch is
199/// created (before the worktree checkout) and must return the config used for
200/// checkout. Splitting the config writes into these callbacks keeps the CLI's
201/// config I/O helpers (which depend on CLI-specific config serialization and
202/// templates) out of the library while preserving their ordering in the flow.
203///
204/// Emits any library-side progress through `progress` and returns the structured
205/// [`CloneOutcome`]; never prints, mutates the process CWD, or returns
206/// `GitError::Exit`. A missing `refs/remotes/<origin>/<checkout_branch>` after the
207/// fetch is reported as [`GitError::NotFound`] for the caller to map (the CLI
208/// turns an explicit `--branch` miss into its own message).
209pub fn clone(request: CloneRequest<'_>, services: CloneServices<'_>) -> Result<CloneOutcome> {
210    #[cfg(feature = "http")]
211    {
212        clone_impl(request, services, None)
213    }
214    #[cfg(not(feature = "http"))]
215    {
216        clone_impl(request, services)
217    }
218}
219
220/// Like [`clone`], but drives the smart-HTTP transport through a caller-provided
221/// [`HttpClient`] when `http_client` is `Some`.
222///
223/// `None` is exactly [`clone`] (a default [`UreqHttpClient`]). A `Some` client
224/// owns the entire dial for every smart-HTTP request the clone makes (ref
225/// advertisement, pack fetch, and the partial-clone checkout-blob top-up), so a
226/// host can enforce network policy such as SSRF validation when mirroring a
227/// public URL. Non-HTTP clone sources ignore it.
228#[cfg(feature = "http")]
229pub fn clone_with_http_client(
230    request: CloneRequest<'_>,
231    services: CloneServices<'_>,
232    http_client: Option<&dyn HttpClient>,
233) -> Result<CloneOutcome> {
234    clone_impl(request, services, http_client)
235}
236
237fn clone_impl(
238    request: CloneRequest<'_>,
239    services: CloneServices<'_>,
240    #[cfg(feature = "http")] http_client: Option<&dyn HttpClient>,
241) -> Result<CloneOutcome> {
242    let layout = RepositoryBootstrap::init(InitOptions {
243        git_dir_override: request.git_dir_override.map(Path::to_path_buf),
244        core_worktree: request.core_worktree.map(str::to_string),
245        worktree: request.destination.to_path_buf(),
246        object_format: request.format,
247        object_format_explicit: false,
248        bare: false,
249        initial_branch: CLONE_UNBORN_BRANCH.into(),
250        template_dir: None,
251        copy_template_config: false,
252        separate_git_dir: None,
253        shared_repository: None,
254        ref_storage: request.options.ref_storage,
255        ref_storage_explicit: request.options.ref_storage != RefStorageFormat::Files,
256    })?;
257    let git_dir = layout.git_dir;
258
259    let config = (services.configure)(&git_dir)?;
260    crate::protocol::check_transport_allowed(
261        scheme_for_clone_source(request.source),
262        Some(&config),
263        None,
264    )
265    .map_err(crate::protocol::transport_policy_git_error)?;
266    let fetch_source = match request.source {
267        #[cfg(feature = "http")]
268        CloneSource::Http(remote) => FetchSource::Http(remote.clone()),
269        #[cfg(not(feature = "http"))]
270        CloneSource::Http(_) => {
271            return Err(GitError::Unsupported(
272                "HTTP transport is not enabled in this build".into(),
273            ));
274        }
275        CloneSource::Ssh(remote) => FetchSource::Ssh(remote.clone()),
276        CloneSource::Git {
277            remote,
278            protocol_v2,
279        } => FetchSource::Git {
280            remote: remote.clone(),
281            protocol_v2: *protocol_v2,
282        },
283        CloneSource::Local {
284            git_dir: remote_git_dir,
285            common_git_dir: remote_common_git_dir,
286        } => FetchSource::Local {
287            git_dir: remote_git_dir.clone(),
288            common_git_dir: remote_common_git_dir.clone(),
289        },
290    };
291    let fetch_options = clone_fetch_options(
292        request.options.depth,
293        request.options.deepen_since,
294        request.options.deepen_not.clone(),
295        request.options.filter.clone(),
296        !request.options.checkout,
297        request.options.reject_shallow,
298        request.options.ssh_options,
299    );
300    let fetch_request = crate::fetch::FetchRequest {
301        git_dir: &git_dir,
302        format: request.format,
303        config: &config,
304        remote_name: request.options.origin,
305        source: &fetch_source,
306        refspecs: &[],
307        options: &fetch_options,
308    };
309    let fetch_services = crate::fetch::FetchServices {
310        credentials: services.credentials,
311        progress: services.progress,
312        ref_hook: None,
313    };
314    #[cfg(feature = "http")]
315    crate::fetch::fetch_with_http_client(fetch_request, fetch_services, http_client)?;
316    #[cfg(not(feature = "http"))]
317    fetch(fetch_request, fetch_services)?;
318
319    let store = FileRefStore::new(&git_dir, request.format);
320    if let Some(detached) = &request.options.detached_head {
321        write_clone_remote_head(&store, request.options)?;
322        if request.options.checkout {
323            sley_worktree::checkout_detached_filtered(
324                request.destination,
325                &git_dir,
326                request.format,
327                detached,
328                request.options.committer.clone(),
329                b"clone: checkout".to_vec(),
330                &config,
331            )?;
332        } else {
333            let mut tx = store.transaction();
334            tx.update(RefUpdate {
335                name: "HEAD".to_string(),
336                expected: None,
337                new: RefTarget::Direct(*detached),
338                reflog: None,
339            });
340            tx.commit()?;
341        }
342        return Ok(CloneOutcome {
343            git_dir,
344            branch_oid: Some(*detached),
345            empty: false,
346        });
347    }
348    let remote_branch_ref = format!(
349        "refs/remotes/{}/{}",
350        request.options.origin, request.options.checkout_branch
351    );
352    let branch_oid = match store.read_ref(&remote_branch_ref)? {
353        Some(RefTarget::Direct(oid)) => oid,
354        Some(RefTarget::Symbolic(_)) => {
355            return Err(GitError::Unsupported(
356                "clone remote-tracking branch must be direct".into(),
357            ));
358        }
359        None => {
360            // The remote advertised no tip for the branch we are tracking. When
361            // the caller did not request an explicit branch this is an
362            // empty/unborn-repository clone: upstream `builtin/clone.c` warns,
363            // skips the checkout, and leaves `HEAD` as an unborn symref pointing
364            // at the remote's (or local default) branch — `update_head`'s
365            // `unborn` arm. We mirror that by setting `HEAD` and returning a
366            // marker for the CLI to print the warning. An explicit-branch miss
367            // is still a hard error (the CLI maps it to git's "Remote branch …
368            // not found" message).
369            if request.options.branch_explicit {
370                return Err(GitError::reference_not_found(format!(
371                    "remote ref {remote_branch_ref}"
372                )));
373            }
374            let unborn = format!("refs/heads/{}", request.options.checkout_branch);
375            let mut tx = store.transaction();
376            tx.update(RefUpdate {
377                name: "HEAD".to_string(),
378                expected: None,
379                new: RefTarget::Symbolic(unborn),
380                reflog: None,
381            });
382            tx.commit()?;
383            // Install branch upstream config for the unborn branch, matching
384            // git's `install_branch_config` in the unborn path.
385            (services.configure_branch)(&git_dir, request.options.checkout_branch)?;
386            return Ok(CloneOutcome {
387                git_dir,
388                branch_oid: None,
389                empty: true,
390            });
391        }
392    };
393    store.create_branch(
394        request.options.checkout_branch,
395        branch_oid.clone(),
396        request.options.committer.clone(),
397        format!(
398            "branch: Created from {}/{}",
399            request.options.origin, request.options.checkout_branch
400        )
401        .into_bytes(),
402    )?;
403    // The branch upstream config is written here and the resulting config is used
404    // for the checkout below, matching the CLI's previous order: configure the
405    // branch, point the remote `HEAD`, then read the (now final) config for the
406    // smudge-side checkout filters. Pointing `HEAD` only updates refs, so it does
407    // not change the config `configure_branch` returns.
408    let checkout_config = (services.configure_branch)(&git_dir, request.options.checkout_branch)?;
409    if request.options.checkout {
410        #[cfg(feature = "http")]
411        fetch_partial_clone_checkout_blobs(
412            &request,
413            &git_dir,
414            branch_oid,
415            services.credentials,
416            http_client,
417        )?;
418        #[cfg(not(feature = "http"))]
419        fetch_partial_clone_checkout_blobs(&request, &git_dir, branch_oid, services.credentials)?;
420    } else {
421        let mut tx = store.transaction();
422        tx.update(RefUpdate {
423            name: "HEAD".to_string(),
424            expected: None,
425            new: RefTarget::Symbolic(format!("refs/heads/{}", request.options.checkout_branch)),
426            reflog: None,
427        });
428        tx.commit()?;
429    }
430    write_clone_remote_head(&store, request.options)?;
431
432    if request.options.checkout {
433        sley_worktree::checkout_branch_filtered(
434            request.destination,
435            &git_dir,
436            request.format,
437            request.options.checkout_branch,
438            request.options.committer.clone(),
439            &checkout_config,
440        )?;
441    }
442
443    Ok(CloneOutcome {
444        git_dir,
445        branch_oid: Some(branch_oid),
446        empty: false,
447    })
448}
449
450fn write_clone_remote_head(store: &FileRefStore, options: &CloneOptions<'_>) -> Result<()> {
451    if options.remote_head_branch.is_empty()
452        || (options.single_branch && options.checkout_branch != options.remote_head_branch)
453    {
454        return Ok(());
455    }
456    let mut tx = store.transaction();
457    tx.update(RefUpdate {
458        name: format!("refs/remotes/{}/HEAD", options.origin),
459        expected: None,
460        new: RefTarget::Symbolic(format!(
461            "refs/remotes/{}/{}",
462            options.origin, options.remote_head_branch
463        )),
464        reflog: None,
465    });
466    tx.commit()
467}
468
469fn scheme_for_clone_source(source: &CloneSource) -> &'static str {
470    match source {
471        CloneSource::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
472        CloneSource::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
473        CloneSource::Git { remote, .. } => crate::protocol::transport_scheme_for_remote(remote),
474        CloneSource::Local { .. } => "file",
475    }
476}
477
478/// Materialize the blobs needed to check out `commit_oid` for a partial clone
479/// that filtered them out of the initial fetch.
480///
481/// A `--filter=blob:limit=…`/`blob:none` clone omits blobs from the initial pack,
482/// but the working-tree checkout still needs the blobs reachable from the checked
483/// out commit's tree. Upstream lazily fetches those blobs from the promisor
484/// remote during checkout; sley fetches them up front here (only the exact
485/// checkout blobs, so unreachable filtered blobs stay absent). The trees are
486/// already present locally (a blob filter keeps trees), so the wanted blob ids
487/// are computed by walking the local copy of the commit's tree.
488fn fetch_partial_clone_checkout_blobs(
489    request: &CloneRequest<'_>,
490    git_dir: &Path,
491    commit_oid: ObjectId,
492    credentials: &mut dyn CredentialProvider,
493    #[cfg(feature = "http")] http_client: Option<&dyn HttpClient>,
494) -> Result<()> {
495    if request.options.filter.is_none() {
496        return Ok(());
497    }
498    match request.source {
499        CloneSource::Local {
500            git_dir: remote_git_dir,
501            common_git_dir: remote_common_git_dir,
502        } => {
503            let local_db = FileObjectDatabase::from_git_dir(git_dir, request.format);
504            let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, request.format);
505            let mut wants = Vec::new();
506            let mut seen = std::collections::HashSet::new();
507            collect_checkout_materialization_wants(
508                &remote_db,
509                &local_db,
510                request.format,
511                commit_oid,
512                &mut seen,
513                &mut wants,
514            )?;
515            crate::local::install_fetch_pack_via_local_upload_pack(
516                git_dir,
517                remote_git_dir,
518                request.format,
519                wants,
520                None,
521                true,
522                false,
523                None,
524                None,
525                false,
526                None,
527            )?;
528            Ok(())
529        }
530        #[cfg(feature = "http")]
531        CloneSource::Http(remote) => fetch_http_partial_clone_checkout_blobs(
532            request,
533            git_dir,
534            commit_oid,
535            remote,
536            credentials,
537            http_client,
538        ),
539        // SSH/git:// partial clones are gated out by the CLI (the in-process
540        // local server is the only transport that advertises object filtering
541        // aside from HTTP), so there is nothing to materialize here.
542        _ => Ok(()),
543    }
544}
545
546/// HTTP arm of [`fetch_partial_clone_checkout_blobs`]: fetch the checkout blobs
547/// from the smart-HTTP promisor remote as a second, targeted fetch (requesting
548/// the exact blob ids via `want <oid>`, which the server permits under
549/// `uploadpack.allowanysha1inwant`). Installed as a `.promisor` pack.
550#[cfg(feature = "http")]
551fn fetch_http_partial_clone_checkout_blobs(
552    request: &CloneRequest<'_>,
553    git_dir: &Path,
554    commit_oid: ObjectId,
555    remote: &RemoteUrl,
556    credentials: &mut dyn CredentialProvider,
557    http_client: Option<&dyn HttpClient>,
558) -> Result<()> {
559    let local_db = FileObjectDatabase::from_git_dir(git_dir, request.format);
560    let mut wants = Vec::new();
561    let mut seen = std::collections::HashSet::new();
562    // The commit and its trees are present locally (only blobs were filtered),
563    // so the local db serves as both the tree source and the presence check.
564    collect_checkout_materialization_wants(
565        &local_db,
566        &local_db,
567        request.format,
568        commit_oid,
569        &mut seen,
570        &mut wants,
571    )?;
572    if wants.is_empty() {
573        return Ok(());
574    }
575    // Use the caller-injected client when present (host network policy / SSRF
576    // guard); otherwise construct the default ureq-backed client.
577    let default_client;
578    let client: &dyn HttpClient = match http_client {
579        Some(client) => client,
580        None => {
581            default_client = UreqHttpClient::new();
582            &default_client
583        }
584    };
585    let discovered = crate::http::http_service_advertisements(
586        client,
587        remote,
588        request.format,
589        sley_protocol::GitService::UploadPack,
590        credentials,
591        None,
592    )?;
593    let git_protocol = crate::http::http_git_protocol_header_value(None)?;
594    let pack_request = crate::http::HttpFetchPackRequest {
595        client,
596        git_dir,
597        format: request.format,
598        remote,
599        wants,
600        haves: None,
601        shallow: Vec::new(),
602        deepen: None,
603        promisor: true,
604        max_input_size: None,
605        filter: None,
606        deepen_since: None,
607        deepen_not: Vec::new(),
608        deepen_relative: false,
609        git_protocol: git_protocol.as_deref(),
610        // The client already has the checkout commit and its trees; advertising
611        // them as haves would make the server omit the (filtered-out) blobs we
612        // are explicitly requesting, so suppress haves for this top-up fetch.
613        omit_haves: true,
614    };
615    // This targeted blob top-up installs a `.promisor` pack (the promisor
616    // install path does not surface transfer progress), so a silent sink is
617    // sufficient here.
618    let mut progress = crate::SilentProgress;
619    if let Some(handshake) = discovered.handshake.as_ref() {
620        crate::http::install_fetch_pack_via_http_protocol_v2_fetch(
621            pack_request,
622            handshake,
623            credentials,
624            &mut progress,
625        )?;
626    } else {
627        crate::http::install_fetch_pack_via_http_upload_pack(
628            pack_request,
629            credentials,
630            &mut progress,
631        )?;
632    }
633    Ok(())
634}
635
636fn collect_checkout_materialization_wants(
637    remote_db: &FileObjectDatabase,
638    local_db: &FileObjectDatabase,
639    format: ObjectFormat,
640    commit_oid: ObjectId,
641    seen: &mut std::collections::HashSet<ObjectId>,
642    wants: &mut Vec<ObjectId>,
643) -> Result<()> {
644    let commit_object = remote_db.read_object(&commit_oid)?;
645    if commit_object.object_type != ObjectType::Commit {
646        return Err(GitError::InvalidObject(format!(
647            "expected commit {commit_oid}, found {}",
648            commit_object.object_type.as_str()
649        )));
650    }
651    let commit = Commit::parse_ref(format, &commit_object.body)?;
652    collect_tree_materialization_wants(remote_db, local_db, format, commit.tree, seen, wants)
653}
654
655fn collect_tree_materialization_wants(
656    remote_db: &FileObjectDatabase,
657    local_db: &FileObjectDatabase,
658    format: ObjectFormat,
659    tree_oid: ObjectId,
660    seen: &mut std::collections::HashSet<ObjectId>,
661    wants: &mut Vec<ObjectId>,
662) -> Result<()> {
663    if !seen.insert(tree_oid) {
664        return Ok(());
665    }
666    if !local_db.contains(&tree_oid)? {
667        wants.push(tree_oid);
668    }
669    let tree_object = remote_db.read_object(&tree_oid)?;
670    if tree_object.object_type != ObjectType::Tree {
671        return Err(GitError::InvalidObject(format!(
672            "expected tree {tree_oid}, found {}",
673            tree_object.object_type.as_str()
674        )));
675    }
676    for entry in Tree::parse(format, &tree_object.body)?.entries {
677        if entry.is_tree() {
678            collect_tree_materialization_wants(
679                remote_db, local_db, format, entry.oid, seen, wants,
680            )?;
681        } else if !entry.is_gitlink() {
682            if seen.insert(entry.oid) && !local_db.contains(&entry.oid)? {
683                wants.push(entry.oid);
684            }
685        }
686    }
687    Ok(())
688}
689
690/// The fixed [`FetchOptions`] a clone fetch uses: quiet, auto-follow tags, write
691/// `FETCH_HEAD`, the requested shallow `depth`, and otherwise neutral (no prune, no
692/// `--tags`, not a dry run, not appending). Mirrors the options the CLI's clone
693/// paths passed.
694fn clone_fetch_options(
695    depth: Option<u32>,
696    deepen_since: Option<i64>,
697    deepen_not: Vec<String>,
698    filter: Option<sley_odb::PackObjectFilter>,
699    record_promisor_refs: bool,
700    reject_shallow: bool,
701    ssh_options: Option<crate::ssh::SshTransportOptions>,
702) -> FetchOptions {
703    FetchOptions {
704        quiet: true,
705        auto_follow_tags: true,
706        fetch_all_tags: false,
707        prune: false,
708        prune_tags: false,
709        dry_run: false,
710        force: false,
711        append: false,
712        write_fetch_head: true,
713        tag_option_explicit: false,
714        prune_option_explicit: false,
715        prune_tags_option_explicit: false,
716        refmap: None,
717        depth,
718        merge_srcs: Vec::new(),
719        filter,
720        refetch: false,
721        cloning: true,
722        record_promisor_refs,
723        update_shallow: false,
724        reject_shallow,
725        deepen_relative: false,
726        update_head_ok: false,
727        deepen_since,
728        deepen_not,
729        ssh_options,
730        atomic: false,
731        negotiation_restrict: None,
732        negotiation_include: None,
733    }
734}