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