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}