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