Skip to main content

sley_remote/
fetch.rs

1//! Callable fetch orchestration for HTTP(S) and local (`file://`/path) remotes.
2//!
3//! [`fetch`] sequences the moved transport plumbing ([`crate::http`],
4//! [`crate::local`]) and the protocol codecs ([`sley_protocol`]) into the full
5//! fetch flow: it advertises refs, plans the ref-map for the requested refspecs,
6//! installs the packfile, writes `FETCH_HEAD`, applies the remote-tracking ref
7//! updates, and prunes stale tracking refs. Everything is taken as explicit
8//! parameters — `git_dir`, the [`ObjectFormat`], the repository [`GitConfig`],
9//! the already-resolved remote, and the seam objects ([`CredentialProvider`],
10//! [`ProgressSink`]) — so it never reads process-global state, parses arguments,
11//! or prints. Human-facing prune notices go through the [`ProgressSink`]; the
12//! structured result (applied updates, pruned refs, the remote `HEAD` symref)
13//! comes back in [`FetchOutcome`] for the caller to format.
14//!
15//! Bundle fetch lives in [`crate::bundle`]; SSH uses the dispatch below. The ref-map
16//! / `FETCH_HEAD` / prune helpers are shared so there is a single implementation.
17
18use crate::local::LocalDeepenPlan;
19use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
20use std::fs;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23use std::time::{SystemTime, UNIX_EPOCH};
24
25use sley_config::GitConfig;
26use sley_config::remotes::{
27    remote_config_values, remote_exists, remote_names, rewrite_url_with_config,
28};
29use sley_core::{GitError, ObjectFormat, ObjectId, Result, redact_url_for_display};
30use sley_odb::{
31    FileObjectDatabase, ObjectReader, collect_reachable_object_ids_excluding,
32    collect_reachable_object_ids_tolerating_promised_missing,
33};
34#[cfg(feature = "http")]
35use sley_protocol::ProtocolVersion;
36use sley_protocol::{
37    FetchHeadRecord, FetchRefUpdate, PromisorRemoteAdvertisement, RefAdvertisement, RefSpec,
38    encode_fetch_head, fetch_ref_updates_to_fetch_head, parse_refspec, plan_fetch_ref_updates,
39    refname_matches, refspec_map_source,
40};
41use sley_refs::{FileRefStore, Ref, RefTarget, RefUpdate, ReflogEntry};
42#[cfg(feature = "http")]
43use sley_transport::{HttpClient, UreqHttpClient};
44use sley_transport::{RemoteTransport, RemoteUrl};
45
46use crate::{CredentialProvider, PackGenerationProgress, ProgressSink};
47
48/// How a fetch obtains refs and objects from the remote.
49///
50/// The caller resolves the remote (URL rewriting, repository discovery — all
51/// process-state dependent) and hands `fetch` a concrete transport.
52pub enum FetchSource {
53    /// A smart-HTTP(S) remote at the given already-resolved URL.
54    Http(RemoteUrl),
55    /// An SSH remote at the given already-resolved URL. Fetched by spawning `ssh`
56    /// (the credential seam is unused — the `ssh` program owns authentication).
57    Ssh(RemoteUrl),
58    /// A native anonymous `git://` remote at the given already-resolved URL.
59    Git {
60        remote: RemoteUrl,
61        protocol_v2: bool,
62    },
63    /// A local repository served in-process from `git_dir`.
64    Local {
65        /// The remote repository's `$GIT_DIR`.
66        git_dir: PathBuf,
67        /// The remote repository's common `$GIT_DIR` (object format source).
68        common_git_dir: PathBuf,
69    },
70}
71
72/// Controls for a [`fetch`] run, mirroring the `git fetch` flags the CLI parses.
73#[derive(Debug, Clone)]
74pub struct FetchOptions {
75    /// Suppress prune notices (deletions still happen; only the [`ProgressSink`]
76    /// output is silenced — the caller wires that).
77    pub quiet: bool,
78    /// Explicit transfer-progress policy. `Some(true)` corresponds to
79    /// `--progress`, `Some(false)` to `--no-progress`; `None` leaves terminal
80    /// policy to the embedding wrapper.
81    pub progress: Option<bool>,
82    /// Auto-follow annotated tags pointing at fetched commits.
83    pub auto_follow_tags: bool,
84    /// Fetch every tag (`--tags`), independent of reachability.
85    pub fetch_all_tags: bool,
86    /// Prune remote-tracking refs that no longer exist on the remote.
87    pub prune: bool,
88    /// Prune local tags absent from the remote when pruning is enabled.
89    pub prune_tags: bool,
90    /// Plan and report the fetch without installing objects or updating refs.
91    pub dry_run: bool,
92    /// Force ref updates (`git fetch --force`), equivalent to applying `+` to
93    /// each effective refspec.
94    pub force: bool,
95    /// Append to `FETCH_HEAD` instead of truncating it.
96    pub append: bool,
97    /// Write `FETCH_HEAD` (the CLI's `--write-fetch-head`).
98    pub write_fetch_head: bool,
99    /// Whether the tag option (`--tags`/`--no-tags`) was set explicitly, so the
100    /// configured `remote.<name>.tagopt` must not override it.
101    pub tag_option_explicit: bool,
102    /// Whether the prune option (`--prune`/`--no-prune`) was set explicitly, so
103    /// the configured `remote.<name>.prune`/`fetch.prune` must not override it.
104    pub prune_option_explicit: bool,
105    /// Whether the prune-tags option (`--prune-tags`/`--no-prune-tags`) was set
106    /// explicitly, so configured prune tag options must not override it.
107    pub prune_tags_option_explicit: bool,
108    /// Explicit `--refmap` mappings for command-line refspec tracking updates.
109    /// `None` means use `remote.<name>.fetch`; `Some([])` disables the
110    /// opportunistic tracking update.
111    pub refmap: Option<Vec<String>>,
112    /// Shallow fetch depth (`--depth N`): truncate history to `N` commits per tip.
113    /// `None` is a full fetch. Honored by the HTTP and SSH transports and by the
114    /// in-process local (`file://`/path) server, which computes the deepen
115    /// boundary itself (see [`crate::local::compute_local_deepen`]).
116    pub depth: Option<u32>,
117    /// When fetching configured remote refspecs, mark updates whose `src`
118    /// matches one of these (possibly-abbreviated) `branch.<name>.merge` values
119    /// as eligible for merge in `FETCH_HEAD`. More than one entry is an octopus
120    /// merge config. Empty falls back to git's default (first ref of the first
121    /// non-pattern configured refspec). Used by `fetch` (current-branch merge
122    /// config) and `pull`.
123    pub merge_srcs: Vec<String>,
124    /// Partial-clone object filter (`--filter=blob:none`): omit filtered
125    /// objects from the transferred pack. Local-only today: HTTP and SSH do not
126    /// send `filter` requests yet, so callers that require network filtering
127    /// must gate that before calling [`fetch`]. Directly-wanted tips are always
128    /// packed on the local path, mirroring upstream's filter traversal.
129    pub filter: Option<sley_odb::PackObjectFilter>,
130    /// Resolve the transfer filter from accepted advertised promisor fields.
131    pub filter_auto: bool,
132    /// `--refetch`: ignore local haves so existing reachable commits can be
133    /// repacked under a newly requested partial-clone filter.
134    pub refetch: bool,
135    /// This fetch is a clone (`fetch_pack_args.cloning`): shallow points sent
136    /// by a shallow server are accepted into `$GIT_DIR/shallow` unconditionally.
137    pub cloning: bool,
138    /// Whether an in-process local promisor install should append the wanted ref
139    /// names to the `.promisor` sidecar. No-checkout partial clone keeps these
140    /// lines; checkout hydration leaves the final sidecar empty like upstream.
141    pub record_promisor_refs: bool,
142    /// `--update-shallow`: accept new shallow points from a shallow server
143    /// (otherwise refs whose history needs them are rejected).
144    pub update_shallow: bool,
145    /// `--reject-shallow` during clone: refuse a shallow source repository.
146    pub reject_shallow: bool,
147    /// `--deepen=N`: `depth` is relative to the client's current boundary.
148    /// Local-only today; HTTP and SSH treat `depth` as an absolute `--depth N`.
149    pub deepen_relative: bool,
150    /// Allow updating the currently checked-out branch (`git fetch -u` /
151    /// `--update-head-ok`). Porcelain `pull` uses this internally.
152    pub update_head_ok: bool,
153    /// `--shallow-since=<date>`: deepen to commits newer than the date.
154    /// Local-only today; HTTP and SSH do not send `deepen-since` yet.
155    pub deepen_since: Option<i64>,
156    /// `--shallow-exclude=<ref>`: deepen to commits not reachable from the ref
157    /// (resolved on the remote; a non-ref is an error, like upstream).
158    /// Local-only today; HTTP and SSH do not send `deepen-not` yet.
159    pub deepen_not: Vec<String>,
160    /// Command-line SSH process options supplied by a higher-level porcelain
161    /// such as clone (`-4`/`-6`). When absent, fetch derives SSH options from
162    /// the effective repository config.
163    pub ssh_options: Option<crate::ssh::SshTransportOptions>,
164    /// Explicit SSH upload-pack program (`--upload-pack` / `--exec`). `None`
165    /// uses the protocol's `git-upload-pack` service name.
166    pub upload_pack_command: Option<String>,
167    /// `--atomic`: apply every remote-tracking ref update (and prune deletion)
168    /// in a single reference transaction so a single rejected update aborts the
169    /// whole fetch and leaves `FETCH_HEAD` empty. The default is non-atomic:
170    /// each ref is updated independently and a per-ref failure is reported but
171    /// does not block the others.
172    pub atomic: bool,
173    /// Command-line `--negotiation-tip`/`--negotiation-restrict` values. `None`
174    /// means use `remote.<name>.negotiationRestrict`; `Some` means the CLI list
175    /// overrides remote config.
176    pub negotiation_restrict: Option<Vec<String>>,
177    /// Command-line `--negotiation-include` values. `None` means use
178    /// `remote.<name>.negotiationInclude`; `Some` means the CLI list overrides
179    /// remote config.
180    pub negotiation_include: Option<Vec<String>>,
181}
182
183/// A remote-tracking ref removed by a prune pass.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct PrunedRef {
186    /// The short branch name on the remote (e.g. `topic`).
187    pub branch: String,
188    /// The full local ref name removed (e.g. `refs/remotes/origin/topic`).
189    pub refname: String,
190}
191
192/// The structured result of a [`fetch`].
193#[derive(Debug, Clone, Default)]
194pub struct FetchOutcome {
195    /// The ref updates that were planned (and applied unless `dry_run`), in the
196    /// order they were resolved. Includes auto-followed tags; entries without a
197    /// `dst` are fetch-only (e.g. a bare `HEAD` fetch) and update no local ref.
198    pub ref_updates: Vec<FetchRefUpdate>,
199    /// Remote-tracking refs pruned (empty unless `prune` and the remote is a
200    /// configured remote). Empty on `dry_run`.
201    pub pruned: Vec<PrunedRef>,
202    /// The remote's advertised `HEAD` symref target (e.g. `refs/heads/main`),
203    /// when the remote advertised one. Useful for resolving the default branch.
204    pub head_symref: Option<String>,
205    /// Whether `FETCH_HEAD` was written.
206    pub wrote_fetch_head: bool,
207    /// Promisor remotes accepted from the server, in advertised priority order.
208    pub accepted_promisor_remotes: Vec<PromisorRemoteAdvertisement>,
209    /// Accepted advertised fields persisted into existing client remotes.
210    pub stored_promisor_fields: Vec<crate::PromisorRemoteFieldUpdate>,
211    /// Equal-work counts for the object transfer, when this transport exposes
212    /// truthful native counts. `None` means no objects moved or the transport
213    /// did not provide trustworthy progress metadata.
214    pub pack_generation_progress: Option<PackGenerationProgress>,
215}
216
217/// Repository-derived defaults for one fetch invocation.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct FetchRepositoryPlan {
220    /// Explicit remote, or the current branch/sole-remote/`origin` default.
221    pub remote: String,
222    /// Current branch `branch.<name>.merge` values when its remote matches.
223    pub merge_srcs: Vec<String>,
224}
225
226/// Plan the repository-dependent fetch defaults from an injected config/ref
227/// snapshot. This performs no repository discovery or filesystem access.
228pub fn plan_fetch_repository(
229    config: &GitConfig,
230    current_branch: Option<&str>,
231    requested_remote: Option<&str>,
232) -> FetchRepositoryPlan {
233    let remote = requested_remote.map(str::to_string).unwrap_or_else(|| {
234        current_branch
235            .and_then(|branch| config.get("branch", Some(branch), "remote"))
236            .map(str::to_string)
237            .unwrap_or_else(|| match remote_names(config).as_slice() {
238                [only] => only.clone(),
239                _ => "origin".to_string(),
240            })
241    });
242    let merge_srcs = current_branch
243        .filter(|branch| config.get("branch", Some(branch), "remote") == Some(remote.as_str()))
244        .map(|branch| {
245            config
246                .get_all("branch", Some(branch), "merge")
247                .into_iter()
248                .flatten()
249                .map(str::to_string)
250                .collect()
251        })
252        .unwrap_or_default();
253    FetchRepositoryPlan { remote, merge_srcs }
254}
255
256/// Fully resolved inputs for a [`fetch`] run.
257pub struct FetchRequest<'a> {
258    /// Local repository `$GIT_DIR`.
259    pub git_dir: &'a Path,
260    /// Local repository object format.
261    pub format: ObjectFormat,
262    /// Local repository config snapshot.
263    pub config: &'a GitConfig,
264    /// Remote name or source string used for config lookup and `FETCH_HEAD`.
265    pub remote_name: &'a str,
266    /// Already-resolved transport source.
267    pub source: &'a FetchSource,
268    /// Refspecs requested by the caller. Empty means configured fetch refspecs,
269    /// falling back to `HEAD`.
270    pub refspecs: &'a [String],
271    /// Fetch behavior flags.
272    pub options: &'a FetchOptions,
273    /// Optional incoming-object validation resolved by the caller. The CLI
274    /// supplies this for Git-compatible fetch/transfer fsck policy; embedders
275    /// may omit it when they own validation separately.
276    pub validation: Option<&'a sley_fsck::FsckPolicy>,
277}
278
279/// Mutable seams used while fetching.
280pub struct FetchServices<'a> {
281    /// Credential source for authenticated transports.
282    pub credentials: &'a mut dyn CredentialProvider,
283    /// Progress sink for prune notices.
284    pub progress: &'a mut dyn ProgressSink,
285    /// `reference-transaction` hook handler fired when applying remote-tracking
286    /// ref updates. `None` skips the hook (the historical behavior). The CLI
287    /// supplies a runner so `--atomic` fetches honor a hook that aborts the
288    /// transaction, matching git's `store_updated_refs`.
289    pub ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
290}
291
292/// Fetch from a resolved `source` into the repository at `git_dir`.
293///
294/// Performs the work the CLI's `fetch_http_repository`/`fetch_local_repository`
295/// did: applies configured tag/prune options, plans the ref-map for `refspecs`
296/// (empty means the remote's configured fetch refspecs, falling back to `HEAD`),
297/// installs the pack, writes `FETCH_HEAD`, applies remote-tracking updates, and
298/// prunes. `remote_name` is the remote/argument the caller resolved `source`
299/// from (used for `FETCH_HEAD` descriptions and to look up `remote.<name>.*`).
300///
301/// Emits prune notices through `progress` and returns the structured
302/// [`FetchOutcome`]; never prints or returns `GitError::Exit`.
303///
304/// The smart-HTTP transport constructs a default [`UreqHttpClient`]. Hosts that
305/// must enforce network policy on the dial (e.g. SSRF-guard a public mirror URL)
306/// call [`fetch_with_http_client`] instead to inject their own [`HttpClient`].
307pub fn fetch(request: FetchRequest<'_>, services: FetchServices<'_>) -> Result<FetchOutcome> {
308    #[cfg(feature = "http")]
309    {
310        fetch_impl(request, services, None)
311    }
312    #[cfg(not(feature = "http"))]
313    {
314        fetch_impl(request, services)
315    }
316}
317
318/// Like [`fetch`], but drives the smart-HTTP transport through a caller-provided
319/// [`HttpClient`] when `http_client` is `Some`.
320///
321/// `None` is exactly [`fetch`] (a default [`UreqHttpClient`]). A `Some` client
322/// owns the entire dial (DNS→connect→TLS) for every smart-HTTP request, letting a
323/// host enforce network policy such as SSRF validation on the resolved IP. Only
324/// the HTTP transport consults the injected client; SSH/git/local sources ignore
325/// it.
326#[cfg(feature = "http")]
327pub fn fetch_with_http_client(
328    request: FetchRequest<'_>,
329    services: FetchServices<'_>,
330    http_client: Option<&dyn HttpClient>,
331) -> Result<FetchOutcome> {
332    fetch_impl(request, services, http_client)
333}
334
335fn fetch_impl(
336    request: FetchRequest<'_>,
337    services: FetchServices<'_>,
338    #[cfg(feature = "http")] http_client: Option<&dyn HttpClient>,
339) -> Result<FetchOutcome> {
340    let ref_hook = services.ref_hook;
341    let mut options = request.options.clone();
342    apply_configured_remote_tag_option(request.config, request.remote_name, &mut options);
343    apply_configured_fetch_prune_option(request.config, request.remote_name, &mut options);
344    normalize_relative_deepen_for_complete_repository(
345        request.git_dir,
346        request.format,
347        &mut options,
348    )?;
349    crate::protocol::check_transport_allowed(
350        scheme_for_fetch_source(request.source),
351        Some(request.config),
352        None,
353    )
354    .map_err(crate::protocol::transport_policy_git_error)?;
355    // A pack must be installed as a promisor pack when the remote is already a
356    // promisor remote OR this fetch applies an object filter: a filtered fetch
357    // omits objects, so its pack is only valid as a `.promisor` pack (git's
358    // fetch-pack writes `.promisor` whenever the request carries a filter).
359    let promisor_remote = request
360        .config
361        .get_bool("remote", Some(request.remote_name), "promisor")
362        .unwrap_or(false)
363        || request.options.filter.is_some()
364        || request.options.filter_auto;
365    let max_input_size = fetch_max_input_size(request.config);
366    let configured_refspecs = if request.refspecs.is_empty() {
367        remote_config_values(request.config, request.remote_name, "fetch")
368    } else {
369        Vec::new()
370    };
371    let configured_refspecs_empty = configured_refspecs.is_empty();
372    // git's `get_ref_map`: a default fetch (no command-line refspecs) of the
373    // current branch's tracking remote also fetches the branch's
374    // `branch.<x>.merge` refs (`add_merge_config`) as source-only refs recorded
375    // for-merge in FETCH_HEAD. When the remote has no configured fetch refspec
376    // either, those merge refs replace the bare-`HEAD` default fetch entirely.
377    let has_merge_config = request.refspecs.is_empty() && !options.merge_srcs.is_empty();
378    let default_head_fetch =
379        request.refspecs.is_empty() && configured_refspecs_empty && !has_merge_config;
380    let configured_remote_fetch = request.refspecs.is_empty() && !configured_refspecs_empty;
381    let fetch_head_source = fetch_head_source_description(request.config, request.remote_name);
382    let prune_refspecs =
383        prune_refspecs_for_source(&configured_refspecs, request.refspecs, options.prune_tags);
384    let mut effective_refspecs = fetch_refspecs_for_source(
385        configured_refspecs,
386        request.refspecs,
387        options.fetch_all_tags,
388    );
389    if options.prune_tags
390        && request.refspecs.is_empty()
391        && !effective_refspecs
392            .iter()
393            .any(|refspec| refspec == "refs/tags/*:refs/tags/*")
394    {
395        effective_refspecs.push("refs/tags/*:refs/tags/*".to_string());
396    }
397    if has_merge_config {
398        // Drop the synthetic bare-`HEAD` refspec the helper inserts when nothing
399        // is configured; the merge refs are fetched for-merge instead.
400        if configured_refspecs_empty && request.refspecs.is_empty() {
401            effective_refspecs.retain(|spec| spec != "HEAD");
402        }
403        // Parse the configured refspecs so coverage (pattern-aware) can be tested
404        // against their sources, mirroring `add_merge_config`'s ref-map lookup.
405        let configured_parsed = effective_refspecs
406            .iter()
407            .map(|refspec| parse_refspec(refspec))
408            .collect::<Result<Vec<_>>>()?;
409        for merge_src in &options.merge_srcs {
410            // git fetches a merge ref only when it is not already reachable
411            // through a configured fetch refspec (`add_merge_config`). A glob
412            // refspec like `refs/heads/*` already covers `refs/heads/three`.
413            let covered = configured_parsed.iter().any(|refspec| {
414                refspec
415                    .src
416                    .as_deref()
417                    .is_some_and(|src| refspec_source_covers(refspec, src, merge_src))
418            });
419            if !covered {
420                // Source-only refspec (no `:dst`): fetched and written to
421                // FETCH_HEAD but creating no local ref.
422                effective_refspecs.push(merge_src.clone());
423            }
424        }
425    }
426    let mut parsed_refspecs = effective_refspecs
427        .iter()
428        .map(|refspec| parse_refspec(refspec))
429        .collect::<Result<Vec<_>>>()?;
430    if options.force {
431        for refspec in &mut parsed_refspecs {
432            refspec.force = true;
433        }
434    }
435    if options.refmap.is_some() && request.refspecs.is_empty() {
436        return Err(GitError::Command(
437            "--refmap option is only meaningful with command-line refspec(s)".into(),
438        ));
439    }
440    let tracking_refspec_strings = if request.refspecs.is_empty() {
441        Vec::new()
442    } else {
443        options.refmap.clone().unwrap_or_else(|| {
444            configured_refspecs_for_tracking(request.config, request.remote_name)
445        })
446    };
447    let tracking_refspecs = tracking_refspec_strings
448        .iter()
449        .map(|refspec| parse_refspec(refspec))
450        .collect::<Result<Vec<_>>>()?;
451    let parsed_prune_refspecs = prune_refspecs
452        .iter()
453        .map(|refspec| parse_refspec(refspec))
454        .collect::<Result<Vec<_>>>()?;
455
456    let store = FileRefStore::new(request.git_dir, request.format);
457    let negotiation_haves = custom_negotiation_haves(
458        request.git_dir,
459        request.format,
460        request.config,
461        request.remote_name,
462        &options,
463    )?;
464    let mut outcome = FetchOutcome::default();
465    let mut quarantine = request
466        .validation
467        .map(|_| {
468            sley_odb::IncomingPackQuarantine::new(request.git_dir, request.format).map(|incoming| {
469                // A filtered request creates a promisor pack even when the
470                // remote was not previously configured as promisor. Validate
471                // the quarantined graph with that same missing-object policy;
472                // otherwise the deliberately omitted blobs are rejected before
473                // the `.promisor` pack can be promoted.
474                incoming.with_promisor_remote_present(promisor_remote)
475            })
476        })
477        .transpose()?;
478    let transfer_git_dir = quarantine
479        .as_ref()
480        .map(sley_odb::IncomingPackQuarantine::git_dir)
481        .unwrap_or(request.git_dir);
482
483    // Advertise refs, plan the ref-map, install the pack, then update refs/prune.
484    // The two transports differ only in how they advertise and how they pull the
485    // pack; the ref-map planning and ref bookkeeping are identical.
486    let advertisements = match request.source {
487        #[cfg(not(feature = "http"))]
488        FetchSource::Http(_) => {
489            return Err(GitError::Unsupported(
490                "HTTP transport is not enabled in this build".into(),
491            ));
492        }
493        #[cfg(feature = "http")]
494        FetchSource::Http(remote) => {
495            // Use the caller-injected client when present (host network policy /
496            // SSRF guard); otherwise construct the default ureq-backed client.
497            let default_client;
498            let client: &dyn HttpClient = match http_client {
499                Some(client) => client,
500                None => {
501                    default_client = UreqHttpClient::new();
502                    &default_client
503                }
504            };
505            let git_protocol = crate::http::http_git_protocol_header_value(Some(request.config))?;
506            let discovered = crate::http::http_service_advertisements(
507                client,
508                remote,
509                request.format,
510                sley_protocol::GitService::UploadPack,
511                services.credentials,
512                Some(request.config),
513            )?;
514            let advertisements = discovered.set.refs;
515            let features = crate::http::http_upload_pack_features(
516                &advertisements,
517                discovered.handshake.as_ref(),
518            )?;
519            outcome.head_symref = head_symref_from_features(&features.symrefs);
520            let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
521                advertisements: &advertisements,
522                refspecs: &parsed_refspecs,
523                options: &options,
524                store: &store,
525                reachable: None,
526                local_db: None,
527                deepen_excluded: None,
528                format: request.format,
529                configured_remote_fetch,
530                has_merge_config,
531                tracking_refspecs: &tracking_refspecs,
532            })?;
533            let wants = updates.iter().map(|update| update.oid).collect();
534            // Shallow fetch: replay the current boundary as `shallow` lines and ask
535            // the server to deepen to `depth`, then fold the server's shallow-info
536            // back into `$GIT_DIR/shallow`. A `None` depth keeps the full-fetch path.
537            let existing_shallow =
538                shallow_boundary_for_request(request.git_dir, request.format, &options)?;
539            let deepen_not = resolve_deepen_not_refs(&advertisements, &options.deepen_not)?;
540            let pack_request = crate::http::HttpFetchPackRequest {
541                client,
542                git_dir: transfer_git_dir,
543                format: request.format,
544                remote,
545                wants,
546                haves: negotiation_haves.clone(),
547                shallow: existing_shallow,
548                deepen: options.depth,
549                promisor: promisor_remote,
550                max_input_size,
551                filter: options.filter.clone(),
552                deepen_since: options.deepen_since,
553                deepen_not,
554                deepen_relative: options.deepen_relative,
555                git_protocol: git_protocol.as_deref(),
556                post_buffer: crate::http::http_post_buffer(Some(request.config)),
557                omit_haves: false,
558            };
559            let shallow_info = if discovered.set.protocol == ProtocolVersion::V2 {
560                let handshake = discovered.handshake.as_ref().ok_or_else(|| {
561                    GitError::InvalidFormat(
562                        "protocol v2 HTTP fetch requires a v2 handshake from service discovery"
563                            .into(),
564                    )
565                })?;
566                crate::http::install_fetch_pack_via_http_protocol_v2_fetch(
567                    pack_request,
568                    handshake,
569                    services.credentials,
570                    services.progress,
571                )?
572            } else {
573                crate::http::install_fetch_pack_via_http_upload_pack(
574                    pack_request,
575                    services.credentials,
576                    services.progress,
577                )?
578            };
579            reject_shallow_clone_fetch(&options, &shallow_info)?;
580            finalize_fetch(
581                FetchFinalize {
582                    git_dir: request.git_dir,
583                    format: request.format,
584                    store: &store,
585                    options: &options,
586                    fetch_head_source: &fetch_head_source,
587                    default_head_fetch,
588                    log_all_ref_updates: fetch_log_all_ref_updates(request.config),
589                    ref_hook,
590                    opportunistic_dsts: &opportunistic_dsts,
591                    validation: request.validation,
592                    quarantine: &mut quarantine,
593                    shallow_info: &shallow_info,
594                },
595                &mut updates,
596                &mut outcome,
597            )?;
598            advertisements
599        }
600        FetchSource::Ssh(remote) => {
601            crate::ssh::validate_ssh_fetch_options(
602                options.filter.as_ref(),
603                options.deepen_since,
604                &options.deepen_not,
605            )?;
606            // SSH advertises and pulls the pack by spawning `ssh` (no credential
607            // seam — the `ssh` program authenticates), but the ref-map planning
608            // and ref bookkeeping are the same shared flow as HTTP.
609            let ssh_options = options
610                .ssh_options
611                .unwrap_or_else(|| crate::ssh::ssh_transport_options_from_config(request.config));
612            let (advertisements, features) =
613                crate::ssh::ssh_upload_pack_advertisements_with_command(
614                    remote,
615                    request.format,
616                    ssh_options,
617                    options.upload_pack_command.as_deref(),
618                )?;
619            outcome.head_symref = head_symref_from_features(&features.symrefs);
620            let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
621                advertisements: &advertisements,
622                refspecs: &parsed_refspecs,
623                options: &options,
624                store: &store,
625                reachable: None,
626                local_db: None,
627                deepen_excluded: None,
628                format: request.format,
629                configured_remote_fetch,
630                has_merge_config,
631                tracking_refspecs: &tracking_refspecs,
632            })?;
633            if remote.transport == RemoteTransport::Ext && options.auto_follow_tags {
634                append_missing_ext_advertised_tags(
635                    &advertisements,
636                    &parsed_refspecs,
637                    &store,
638                    &mut updates,
639                )?;
640            }
641            let wants = updates.iter().map(|update| update.oid).collect();
642            // Shallow fetch over SSH mirrors the HTTP path: replay the current
643            // boundary, deepen to `depth`, then apply the server's shallow-info.
644            let existing_shallow =
645                shallow_boundary_for_request(request.git_dir, request.format, &options)?;
646            let shallow_info = crate::ssh::install_fetch_pack_via_ssh_upload_pack(
647                crate::ssh::SshFetchPackRequest {
648                    git_dir: transfer_git_dir,
649                    format: request.format,
650                    remote,
651                    features: &features,
652                    wants,
653                    haves: negotiation_haves.clone(),
654                    shallow: existing_shallow,
655                    deepen: options.depth,
656                    promisor: promisor_remote,
657                    command_options: ssh_options,
658                    upload_pack_command: options.upload_pack_command.as_deref(),
659                    max_input_size,
660                },
661                services.progress,
662            )?;
663            reject_shallow_clone_fetch(&options, &shallow_info)?;
664            finalize_fetch(
665                FetchFinalize {
666                    git_dir: request.git_dir,
667                    format: request.format,
668                    store: &store,
669                    options: &options,
670                    fetch_head_source: &fetch_head_source,
671                    default_head_fetch,
672                    log_all_ref_updates: fetch_log_all_ref_updates(request.config),
673                    ref_hook,
674                    opportunistic_dsts: &opportunistic_dsts,
675                    validation: request.validation,
676                    quarantine: &mut quarantine,
677                    shallow_info: &shallow_info,
678                },
679                &mut updates,
680                &mut outcome,
681            )?;
682            advertisements
683        }
684        FetchSource::Git {
685            remote,
686            protocol_v2,
687        } => {
688            let protocol_v2 =
689                *protocol_v2 || request.config.get("protocol", None, "version") == Some("2");
690            let exact_oid_only = parsed_refspecs.iter().any(|refspec| !refspec.negative)
691                && parsed_refspecs.iter().all(|refspec| {
692                    refspec.negative
693                        || (!refspec.pattern
694                            && refspec.src.as_deref().is_some_and(|source| {
695                                ObjectId::from_hex(request.format, source).is_ok()
696                            }))
697                });
698            let discovered = if protocol_v2 && exact_oid_only && !options.auto_follow_tags {
699                // Protocol v2 permits an exact-OID fetch to start directly with
700                // `command=fetch`; no `ls-refs` command is needed. The fetch
701                // connection below still validates the server handshake and
702                // object format before sending the want.
703                crate::git::GitUploadPackAdvertisements {
704                    refs: Vec::new(),
705                    features: sley_protocol::UploadPackFeatures {
706                        object_format: Some(request.format),
707                        ..sley_protocol::UploadPackFeatures::default()
708                    },
709                    protocol_v2: true,
710                    object_format: request.format,
711                }
712            } else {
713                crate::git::git_upload_pack_advertisements_with_protocol(
714                    remote,
715                    request.format,
716                    protocol_v2,
717                    Some(request.config),
718                )?
719            };
720            let advertisements = discovered.refs;
721            let features = discovered.features;
722            outcome.head_symref = head_symref_from_features(&features.symrefs);
723            let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
724                advertisements: &advertisements,
725                refspecs: &parsed_refspecs,
726                options: &options,
727                store: &store,
728                reachable: None,
729                local_db: None,
730                deepen_excluded: None,
731                format: request.format,
732                configured_remote_fetch,
733                has_merge_config,
734                tracking_refspecs: &tracking_refspecs,
735            })?;
736            let wants = updates.iter().map(|update| update.oid).collect();
737            let existing_shallow =
738                shallow_boundary_for_request(request.git_dir, request.format, &options)?;
739            let shallow_info = crate::git::install_fetch_pack_via_git_upload_pack(
740                crate::git::GitFetchPackRequest {
741                    git_dir: transfer_git_dir,
742                    format: request.format,
743                    remote,
744                    config: Some(request.config),
745                    features: &features,
746                    wants,
747                    haves: negotiation_haves.clone(),
748                    shallow: existing_shallow,
749                    deepen: options.depth,
750                    promisor: promisor_remote,
751                    protocol_v2: discovered.protocol_v2,
752                    max_input_size,
753                },
754                services.progress,
755            )?;
756            reject_shallow_clone_fetch(&options, &shallow_info)?;
757            finalize_fetch(
758                FetchFinalize {
759                    git_dir: request.git_dir,
760                    format: request.format,
761                    store: &store,
762                    options: &options,
763                    fetch_head_source: &fetch_head_source,
764                    default_head_fetch,
765                    log_all_ref_updates: fetch_log_all_ref_updates(request.config),
766                    ref_hook,
767                    opportunistic_dsts: &opportunistic_dsts,
768                    validation: request.validation,
769                    quarantine: &mut quarantine,
770                    shallow_info: &shallow_info,
771                },
772                &mut updates,
773                &mut outcome,
774            )?;
775            advertisements
776        }
777        FetchSource::Local {
778            git_dir: remote_git_dir,
779            common_git_dir: remote_common_git_dir,
780        } => {
781            let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
782            if remote_format != request.format {
783                return Err(GitError::InvalidObjectId(format!(
784                    "remote repository uses {}, local repository uses {}",
785                    remote_format.name(),
786                    request.format.name()
787                )));
788            }
789            let advertisements =
790                crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
791            let remote_config =
792                sley_config::read_repo_config(remote_common_git_dir, None).unwrap_or_default();
793            let mut promisor_decision = crate::PromisorRemoteDecision::default();
794            if let Some(capability) = crate::promisor_remote_server_capability(&remote_config)? {
795                let advertised = format!(
796                    "promisor-remote={}\n",
797                    capability.value.as_deref().unwrap_or("")
798                );
799                sley_protocol::trace_packet_read_payload(advertised.as_bytes());
800                promisor_decision =
801                    crate::decide_promisor_remote_reply(request.config, &capability)?;
802                if let Some(remote) = promisor_decision.accepted.first() {
803                    crate::promisor::trace_promisor_remote_contact(&remote.name);
804                }
805                if options.filter_auto {
806                    options.filter = crate::promisor_remote_auto_filter(&promisor_decision);
807                }
808                outcome.accepted_promisor_remotes = promisor_decision.accepted.clone();
809                crate::apply_promisor_remote_field_updates(
810                    request.git_dir,
811                    &promisor_decision.stored_fields,
812                )?;
813                for update in &promisor_decision.stored_fields {
814                    services.progress.diagnostic(&format!(
815                        "Storing new {} from server for remote '{}'.",
816                        update.field.display_name(),
817                        update.remote_name
818                    ));
819                    services.progress.diagnostic(&format!(
820                        "    '{}' -> '{}'",
821                        update.previous.as_deref().unwrap_or(""),
822                        update.value
823                    ));
824                }
825                outcome.stored_promisor_fields = promisor_decision.stored_fields.clone();
826                if let Some(reply) = promisor_decision.reply.as_deref() {
827                    sley_protocol::trace_packet_write_payload(
828                        format!("promisor-remote={reply}\n").as_bytes(),
829                    );
830                }
831            }
832            // The remote's advertised HEAD symref target (e.g. `refs/heads/main`),
833            // used by the CLI to create `refs/remotes/<remote>/HEAD` on a default
834            // fetch — parity with the network transports' `head_symref`.
835            if advertisements
836                .iter()
837                .any(|advertisement| advertisement.name == "HEAD")
838                && let Some(RefTarget::Symbolic(target)) =
839                    FileRefStore::new_without_reference_backend_env(remote_git_dir, request.format)
840                        .read_ref("HEAD")?
841            {
842                outcome.head_symref = Some(target);
843            }
844            let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, request.format)
845                .with_promisor_remote_present(crate::config_has_promisor_remote(&remote_config));
846            // Shallow fetch: the in-process upload-pack needs its deepen plan up
847            // front. The boundary walk starts from the primary planned tips
848            // (upload-pack's `want_obj`) — auto-followed tags are this path's
849            // include-tag equivalent and must not deepen the walk, and the tag
850            // auto-follow below must not see history past the boundary. The
851            // primary plan is recomputed inside `plan_and_adjust_updates`; the
852            // planner is a pure function over the same inputs, so both runs
853            // agree. A `None` depth keeps the full-fetch path.
854            // The remote's own boundary: a shallow server reports its graft
855            // points on ANY fetch (upstream `send_shallow_info` runs an
856            // implicit INFINITE_DEPTH deepen when no deepen was requested).
857            let remote_shallow =
858                crate::shallow::read_shallow(remote_common_git_dir, request.format)?;
859            let explicit_deepen = options.depth.is_some()
860                || options.deepen_since.is_some()
861                || !options.deepen_not.is_empty();
862            let implicit_deepen = !explicit_deepen && !remote_shallow.is_empty();
863            // `--shallow-exclude` values must name refs on the remote
864            // (upstream upload-pack `process_deepen_not`).
865            let mut deepen_not_oids = Vec::new();
866            for name in &options.deepen_not {
867                let resolved = advertisements.iter().find(|advertisement| {
868                    advertisement.name == *name
869                        || advertisement.name == format!("refs/tags/{name}")
870                        || advertisement.name == format!("refs/heads/{name}")
871                        || advertisement.name == format!("refs/{name}")
872                });
873                match resolved {
874                    Some(advertisement) => deepen_not_oids.push(advertisement.oid),
875                    None => {
876                        return Err(GitError::Command(format!(
877                            "git upload-pack: deepen-not is not a ref: {name}"
878                        )));
879                    }
880                }
881            }
882            let plan_deepen = |heads: &[ObjectId]| -> Result<Option<LocalDeepenPlan>> {
883                if !explicit_deepen && !implicit_deepen {
884                    return Ok(None);
885                }
886                // Replay the current boundary, like the HTTP and SSH paths.
887                let client_shallow = crate::shallow::read_shallow(request.git_dir, request.format)?;
888                if options.deepen_since.is_some() || !deepen_not_oids.is_empty() {
889                    return Ok(Some(crate::local::compute_local_deepen_by_rev_list(
890                        &remote_db,
891                        request.format,
892                        heads,
893                        client_shallow,
894                        options.deepen_since,
895                        &deepen_not_oids,
896                    )?));
897                }
898                let depth = options.depth.unwrap_or(crate::local::INFINITE_DEPTH);
899                Ok(Some(crate::local::compute_local_deepen(
900                    &remote_db,
901                    request.format,
902                    heads,
903                    client_shallow,
904                    depth,
905                    options.deepen_relative,
906                )?))
907            };
908            let primary_heads = {
909                let primary = plan_fetch_ref_updates(
910                    request.format,
911                    &advertisements,
912                    &parsed_refspecs,
913                    options.auto_follow_tags,
914                )?;
915                let mut seen = HashSet::new();
916                let mut heads = Vec::new();
917                for update in &primary {
918                    if seen.insert(update.oid) {
919                        heads.push(update.oid);
920                    }
921                }
922                heads
923            };
924            let mut deepen_plan = plan_deepen(&primary_heads)?;
925            let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
926            let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
927                advertisements: &advertisements,
928                refspecs: &parsed_refspecs,
929                options: &options,
930                store: &store,
931                reachable: Some((&remote_db, &advertisements)),
932                local_db: Some(&local_db),
933                deepen_excluded: deepen_plan.as_ref().map(|plan| &plan.excluded),
934                format: request.format,
935                configured_remote_fetch,
936                has_merge_config,
937                tracking_refspecs: &tracking_refspecs,
938            })?;
939            // A shallow server's new boundary points are only written on a
940            // clone, an explicit deepen, or `--update-shallow`; otherwise the
941            // refs whose history would need them are rejected and dropped
942            // (upstream fetch-pack `update_shallow` + REF_STATUS_REJECT_SHALLOW).
943            if implicit_deepen && !options.cloning && !options.update_shallow {
944                let client_shallow: HashSet<ObjectId> =
945                    crate::shallow::read_shallow(request.git_dir, request.format)?
946                        .into_iter()
947                        .collect();
948                let new_points: HashSet<ObjectId> = deepen_plan
949                    .as_ref()
950                    .map(|plan| {
951                        plan.shallow_info
952                            .iter()
953                            .filter_map(|entry| match entry {
954                                sley_protocol::ProtocolV2FetchShallowInfo::Shallow(oid)
955                                    if !client_shallow.contains(oid) =>
956                                {
957                                    Some(*oid)
958                                }
959                                _ => None,
960                            })
961                            .collect()
962                    })
963                    .unwrap_or_default();
964                if !new_points.is_empty() {
965                    let mut dirty_cache: HashMap<ObjectId, bool> = HashMap::new();
966                    let mut dirty = |tip: &ObjectId| -> Result<bool> {
967                        if let Some(&cached) = dirty_cache.get(tip) {
968                            return Ok(cached);
969                        }
970                        let result =
971                            tip_reaches_boundary(&remote_db, request.format, tip, &new_points)?;
972                        dirty_cache.insert(*tip, result);
973                        Ok(result)
974                    };
975                    let mut kept = Vec::new();
976                    for update in updates {
977                        if dirty(&update.oid)? {
978                            continue;
979                        }
980                        kept.push(update);
981                    }
982                    updates = kept;
983                    // Re-plan the boundary from the surviving tips so the pack
984                    // walk and the shallow-info reflect only what is sent.
985                    let mut seen = HashSet::new();
986                    let mut heads = Vec::new();
987                    for update in &updates {
988                        if seen.insert(update.oid) {
989                            heads.push(update.oid);
990                        }
991                    }
992                    deepen_plan = if heads.is_empty() {
993                        None
994                    } else {
995                        plan_deepen(&heads)?
996                    };
997                }
998            }
999            let starts: Vec<ObjectId> = if options.refetch {
1000                let mut seen = HashSet::new();
1001                updates
1002                    .iter()
1003                    .map(|update| update.oid)
1004                    .chain(primary_heads.iter().copied())
1005                    .filter(|oid| seen.insert(*oid))
1006                    .collect()
1007            } else if deepen_plan.is_none() {
1008                let mut starts = Vec::new();
1009                for update in &updates {
1010                    if !local_db.contains(&update.oid)? {
1011                        starts.push(update.oid);
1012                    }
1013                }
1014                starts
1015            } else {
1016                updates.iter().map(|update| update.oid).collect()
1017            };
1018            let use_ref_in_want = deepen_plan.is_none()
1019                && !promisor_remote
1020                && options.filter.is_none()
1021                && !options.refetch
1022                && request.config.get("protocol", None, "version") == Some("2")
1023                && sley_config::read_repo_config(remote_git_dir, None)
1024                    .ok()
1025                    .and_then(|config| config.get_bool("uploadpack", None, "allowrefinwant"))
1026                    .unwrap_or(false);
1027            let mut seen_want_refs = HashSet::new();
1028            let want_refs: Vec<String> = if use_ref_in_want {
1029                updates
1030                    .iter()
1031                    .filter(|update| update.src.starts_with("refs/"))
1032                    .filter(|update| seen_want_refs.insert(update.src.clone()))
1033                    .map(|update| update.src.clone())
1034                    .collect()
1035            } else {
1036                Vec::new()
1037            };
1038            let has_exact_oid_want = use_ref_in_want
1039                && updates
1040                    .iter()
1041                    .any(|update| ObjectId::from_hex(request.format, &update.src).is_ok());
1042            let (
1043                shallow_info,
1044                transferred_objects,
1045                transferred_compression_objects,
1046                transferred_deltas,
1047            ) = if !want_refs.is_empty() || has_exact_oid_want {
1048                let mut seen_exact_wants = HashSet::new();
1049                let exact_wants = updates
1050                    .iter()
1051                    .filter(|update| ObjectId::from_hex(request.format, &update.src).is_ok())
1052                    .filter(|update| seen_exact_wants.insert(update.oid))
1053                    .map(|update| update.oid)
1054                    .collect();
1055                let ref_in_want = crate::local::install_fetch_pack_via_local_protocol_v2(
1056                    crate::local::LocalProtocolV2FetchRequest {
1057                        git_dir: request.git_dir,
1058                        destination_git_dir: transfer_git_dir,
1059                        remote_git_dir,
1060                        format: request.format,
1061                        wants: exact_wants,
1062                        want_refs,
1063                        haves: negotiation_haves.clone(),
1064                    },
1065                )?;
1066                for wanted in ref_in_want.wanted_refs {
1067                    for update in &mut updates {
1068                        if update.src == wanted.name {
1069                            update.oid = wanted.oid;
1070                        }
1071                    }
1072                }
1073                (ref_in_want.shallow_info, 0, 0, 0)
1074            } else if starts.is_empty() && deepen_plan.is_none() {
1075                if !updates.is_empty() {
1076                    sley_protocol::trace_packet_write_payload(b"0000");
1077                }
1078                (Vec::new(), 0, 0, 0)
1079            } else {
1080                let transfer = crate::local::install_fetch_pack_via_local_upload_pack_with_promisor_decision_into(
1081                    request.git_dir,
1082                    transfer_git_dir,
1083                    remote_git_dir,
1084                    request.format,
1085                    starts,
1086                    deepen_plan.as_ref(),
1087                    promisor_remote,
1088                    options.record_promisor_refs,
1089                    options.filter.clone(),
1090                    negotiation_haves.clone(),
1091                    options.refetch,
1092                    local_fetch_unpack_limit(request.config, options.cloning, promisor_remote),
1093                    &promisor_decision,
1094                    crate::local::LocalFetchPackRequestMode::Traversal,
1095                )?;
1096                (
1097                    transfer.shallow_info,
1098                    transfer.object_count,
1099                    transfer.compression_count,
1100                    transfer.delta_count,
1101                )
1102            };
1103            outcome.pack_generation_progress =
1104                (transferred_objects > 0).then_some(PackGenerationProgress {
1105                    total_objects: transferred_objects,
1106                    compression_objects: transferred_compression_objects,
1107                    delta_objects: transferred_deltas,
1108                });
1109            if options.progress == Some(true)
1110                && !options.quiet
1111                && let Some(progress) = outcome.pack_generation_progress.as_ref()
1112            {
1113                services.progress.pack_generation(progress);
1114            }
1115            finalize_fetch(
1116                FetchFinalize {
1117                    git_dir: request.git_dir,
1118                    format: request.format,
1119                    store: &store,
1120                    options: &options,
1121                    fetch_head_source: &fetch_head_source,
1122                    default_head_fetch,
1123                    log_all_ref_updates: fetch_log_all_ref_updates(request.config),
1124                    ref_hook,
1125                    opportunistic_dsts: &opportunistic_dsts,
1126                    validation: request.validation,
1127                    quarantine: &mut quarantine,
1128                    shallow_info: &shallow_info,
1129                },
1130                &mut updates,
1131                &mut outcome,
1132            )?;
1133            advertisements
1134        }
1135    };
1136
1137    if options.prune && !parsed_prune_refspecs.is_empty() {
1138        outcome.pruned = prune_refs_from_advertisements(
1139            PruneRefsInput {
1140                config: request.config,
1141                store: &store,
1142                remote: request.remote_name,
1143                advertisements: &advertisements,
1144                refspecs: &parsed_prune_refspecs,
1145                dry_run: options.dry_run,
1146                quiet: options.quiet,
1147            },
1148            services.progress,
1149        )?;
1150    }
1151
1152    Ok(outcome)
1153}
1154
1155/// Final ref bookkeeping for objects installed by a remote helper's `import`
1156/// stream. The helper owns object transfer; this engine operation deliberately
1157/// shares Git-compatible refspec planning, FETCH_HEAD, pruning, and reference
1158/// transactions with [`fetch`].
1159pub struct RemoteHelperFetchRequest<'a> {
1160    pub git_dir: &'a Path,
1161    pub format: ObjectFormat,
1162    pub config: &'a GitConfig,
1163    pub remote_name: &'a str,
1164    pub advertisements: &'a [RefAdvertisement],
1165    pub head_symref: Option<String>,
1166    pub refspecs: &'a [String],
1167    pub options: &'a FetchOptions,
1168}
1169
1170pub fn finalize_remote_helper_fetch(
1171    request: RemoteHelperFetchRequest<'_>,
1172    services: FetchServices<'_>,
1173) -> Result<FetchOutcome> {
1174    let ref_hook = services.ref_hook;
1175    let mut options = request.options.clone();
1176    apply_configured_remote_tag_option(request.config, request.remote_name, &mut options);
1177    apply_configured_fetch_prune_option(request.config, request.remote_name, &mut options);
1178    let configured_refspecs = if request.refspecs.is_empty() {
1179        remote_config_values(request.config, request.remote_name, "fetch")
1180    } else {
1181        Vec::new()
1182    };
1183    let configured_refspecs_empty = configured_refspecs.is_empty();
1184    let has_merge_config = request.refspecs.is_empty() && !options.merge_srcs.is_empty();
1185    let default_head_fetch =
1186        request.refspecs.is_empty() && configured_refspecs_empty && !has_merge_config;
1187    let configured_remote_fetch = request.refspecs.is_empty() && !configured_refspecs_empty;
1188    let fetch_head_source = fetch_head_source_description(request.config, request.remote_name);
1189    let prune_refspecs =
1190        prune_refspecs_for_source(&configured_refspecs, request.refspecs, options.prune_tags);
1191    let mut effective_refspecs = fetch_refspecs_for_source(
1192        configured_refspecs,
1193        request.refspecs,
1194        options.fetch_all_tags,
1195    );
1196    if options.prune_tags
1197        && request.refspecs.is_empty()
1198        && !effective_refspecs
1199            .iter()
1200            .any(|refspec| refspec == "refs/tags/*:refs/tags/*")
1201    {
1202        effective_refspecs.push("refs/tags/*:refs/tags/*".to_string());
1203    }
1204    if has_merge_config {
1205        if configured_refspecs_empty && request.refspecs.is_empty() {
1206            effective_refspecs.retain(|spec| spec != "HEAD");
1207        }
1208        let configured_parsed = effective_refspecs
1209            .iter()
1210            .map(|refspec| parse_refspec(refspec))
1211            .collect::<Result<Vec<_>>>()?;
1212        for merge_src in &options.merge_srcs {
1213            let covered = configured_parsed.iter().any(|refspec| {
1214                refspec
1215                    .src
1216                    .as_deref()
1217                    .is_some_and(|src| refspec_source_covers(refspec, src, merge_src))
1218            });
1219            if !covered {
1220                effective_refspecs.push(merge_src.clone());
1221            }
1222        }
1223    }
1224    let mut parsed_refspecs = effective_refspecs
1225        .iter()
1226        .map(|refspec| parse_refspec(refspec))
1227        .collect::<Result<Vec<_>>>()?;
1228    if options.force {
1229        for refspec in &mut parsed_refspecs {
1230            refspec.force = true;
1231        }
1232    }
1233    if options.refmap.is_some() && request.refspecs.is_empty() {
1234        return Err(GitError::Command(
1235            "--refmap option is only meaningful with command-line refspec(s)".into(),
1236        ));
1237    }
1238    let tracking_refspec_strings = if request.refspecs.is_empty() {
1239        Vec::new()
1240    } else {
1241        options.refmap.clone().unwrap_or_else(|| {
1242            configured_refspecs_for_tracking(request.config, request.remote_name)
1243        })
1244    };
1245    let tracking_refspecs = tracking_refspec_strings
1246        .iter()
1247        .map(|refspec| parse_refspec(refspec))
1248        .collect::<Result<Vec<_>>>()?;
1249    let parsed_prune_refspecs = prune_refspecs
1250        .iter()
1251        .map(|refspec| parse_refspec(refspec))
1252        .collect::<Result<Vec<_>>>()?;
1253
1254    let store = FileRefStore::new(request.git_dir, request.format);
1255    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
1256    let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
1257        advertisements: request.advertisements,
1258        refspecs: &parsed_refspecs,
1259        options: &options,
1260        store: &store,
1261        reachable: Some((&local_db, request.advertisements)),
1262        local_db: Some(&local_db),
1263        deepen_excluded: None,
1264        format: request.format,
1265        configured_remote_fetch,
1266        has_merge_config,
1267        tracking_refspecs: &tracking_refspecs,
1268    })?;
1269    let mut outcome = FetchOutcome {
1270        head_symref: request.head_symref,
1271        ..FetchOutcome::default()
1272    };
1273    let mut quarantine = None;
1274    finalize_fetch(
1275        FetchFinalize {
1276            git_dir: request.git_dir,
1277            format: request.format,
1278            store: &store,
1279            options: &options,
1280            fetch_head_source: &fetch_head_source,
1281            default_head_fetch,
1282            log_all_ref_updates: fetch_log_all_ref_updates(request.config),
1283            ref_hook,
1284            opportunistic_dsts: &opportunistic_dsts,
1285            validation: None,
1286            quarantine: &mut quarantine,
1287            shallow_info: &[],
1288        },
1289        &mut updates,
1290        &mut outcome,
1291    )?;
1292    if options.prune && !parsed_prune_refspecs.is_empty() {
1293        outcome.pruned = prune_refs_from_advertisements(
1294            PruneRefsInput {
1295                config: request.config,
1296                store: &store,
1297                remote: request.remote_name,
1298                advertisements: request.advertisements,
1299                refspecs: &parsed_prune_refspecs,
1300                dry_run: options.dry_run,
1301                quiet: options.quiet,
1302            },
1303            services.progress,
1304        )?;
1305    }
1306    Ok(outcome)
1307}
1308
1309fn scheme_for_fetch_source(source: &FetchSource) -> &str {
1310    match source {
1311        FetchSource::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
1312        FetchSource::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
1313        FetchSource::Git { remote, .. } => crate::protocol::transport_scheme_for_remote(remote),
1314        FetchSource::Local { .. } => "file",
1315    }
1316}
1317
1318fn local_fetch_unpack_limit(
1319    config: &GitConfig,
1320    cloning: bool,
1321    promisor_remote: bool,
1322) -> Option<usize> {
1323    // fetch-pack keeps the initial clone as a pack, but ordinary fetches use
1324    // unpack-objects for transfers smaller than fetch.unpackLimit (falling
1325    // back to transfer.unpackLimit, then Git's built-in default of 100).
1326    // Promisor packs must remain packed so their sidecar metadata can describe
1327    // the promised-object boundary.
1328    if cloning || promisor_remote {
1329        return None;
1330    }
1331    let configured = config
1332        .get("fetch", None, "unpackLimit")
1333        .or_else(|| config.get("transfer", None, "unpackLimit"));
1334    match configured.and_then(sley_config::parse_config_int) {
1335        Some(limit) if limit > 0 => usize::try_from(limit).ok(),
1336        Some(_) => None,
1337        None if configured.is_some() => None,
1338        None => Some(100),
1339    }
1340}
1341
1342/// Does the (graft-aware) history of `tip` on the remote touch one of the
1343/// server's new shallow boundary points? Mirrors upstream
1344/// `assign_shallow_commits_to_refs`'s per-ref reachability test.
1345fn tip_reaches_boundary<R: sley_odb::ObjectReader>(
1346    remote_db: &R,
1347    format: ObjectFormat,
1348    tip: &ObjectId,
1349    boundary: &HashSet<ObjectId>,
1350) -> Result<bool> {
1351    let mut seen: HashSet<ObjectId> = HashSet::new();
1352    let mut queue: Vec<ObjectId> = vec![*tip];
1353    while let Some(oid) = queue.pop() {
1354        if !seen.insert(oid) {
1355            continue;
1356        }
1357        let object = remote_db.read_object(&oid)?;
1358        let commit = match object.object_type {
1359            sley_object::ObjectType::Commit => {
1360                sley_object::Commit::parse_ref(format, &object.body)?
1361            }
1362            sley_object::ObjectType::Tag => {
1363                let tag = sley_object::Tag::parse_ref(format, &object.body)?;
1364                queue.push(tag.object);
1365                continue;
1366            }
1367            _ => continue,
1368        };
1369        if boundary.contains(&oid) {
1370            return Ok(true);
1371        }
1372        queue.extend(sley_odb::grafted_parents(remote_db, &oid, commit.parents));
1373    }
1374    Ok(false)
1375}
1376
1377/// The shallow boundary to replay in a deepen request: the oids in
1378/// `$GIT_DIR/shallow` when `depth` is set, otherwise empty (a full fetch sends no
1379/// `shallow` lines). Reading the file only when deepening keeps the non-shallow
1380/// path's wire form unchanged.
1381fn shallow_boundary_for_request(
1382    git_dir: &Path,
1383    format: ObjectFormat,
1384    options: &FetchOptions,
1385) -> Result<Vec<ObjectId>> {
1386    if options.depth.is_none() && options.deepen_since.is_none() && options.deepen_not.is_empty() {
1387        return Ok(Vec::new());
1388    }
1389    crate::shallow::read_shallow(git_dir, format)
1390}
1391
1392/// `git fetch --deepen=N` is relative to an existing shallow boundary. In a
1393/// complete repository there is no boundary to move, so the fetch proceeds as
1394/// an ordinary full fetch instead of truncating the repository at depth `N`.
1395fn normalize_relative_deepen_for_complete_repository(
1396    git_dir: &Path,
1397    format: ObjectFormat,
1398    options: &mut FetchOptions,
1399) -> Result<()> {
1400    if !options.deepen_relative || options.depth.is_none() {
1401        return Ok(());
1402    }
1403    if crate::shallow::read_shallow(git_dir, format)?.is_empty() {
1404        options.depth = None;
1405        options.deepen_relative = false;
1406    }
1407    Ok(())
1408}
1409
1410fn resolve_deepen_not_refs(
1411    advertisements: &[RefAdvertisement],
1412    deepen_not: &[String],
1413) -> Result<Vec<String>> {
1414    let mut resolved = Vec::with_capacity(deepen_not.len());
1415    for name in deepen_not {
1416        let found = advertisements.iter().any(|advertisement| {
1417            advertisement.name == *name
1418                || advertisement.name == format!("refs/tags/{name}")
1419                || advertisement.name == format!("refs/heads/{name}")
1420                || advertisement.name == format!("refs/{name}")
1421        });
1422        if found {
1423            resolved.push(name.clone());
1424        } else {
1425            return Err(GitError::Command(format!(
1426                "git upload-pack: deepen-not is not a ref: {name}"
1427            )));
1428        }
1429    }
1430    Ok(resolved)
1431}
1432
1433/// Plan the ref-map and apply the auto-follow-tag / not-for-merge adjustments
1434/// shared by both transports. `reachable` (local only) enables appending tags
1435/// reachable from fetched commits via the remote object database;
1436/// `deepen_excluded` (local shallow fetch only) keeps that reachability walk
1437/// from crossing the deepen boundary.
1438struct FetchPlanInput<'a> {
1439    advertisements: &'a [RefAdvertisement],
1440    refspecs: &'a [RefSpec],
1441    options: &'a FetchOptions,
1442    store: &'a FileRefStore,
1443    reachable: Option<(&'a FileObjectDatabase, &'a [RefAdvertisement])>,
1444    /// The local repository's object database, used to follow tags whose target
1445    /// is already present locally (git's `find_non_local_tags` `odb_has_object`
1446    /// check). Only the local transport supplies it; auto-follow is local-only.
1447    local_db: Option<&'a FileObjectDatabase>,
1448    deepen_excluded: Option<&'a HashSet<ObjectId>>,
1449    format: ObjectFormat,
1450    configured_remote_fetch: bool,
1451    /// Default fetch (no command-line refspecs) of the current branch's tracking
1452    /// remote with `branch.<x>.merge` configured. The merge refs drive which
1453    /// FETCH_HEAD entries are for-merge (`add_merge_config`).
1454    has_merge_config: bool,
1455    /// Opportunistic tracking mappings used only for command-line refspecs.
1456    tracking_refspecs: &'a [RefSpec],
1457}
1458
1459fn plan_and_adjust_updates(
1460    input: FetchPlanInput<'_>,
1461) -> Result<(Vec<FetchRefUpdate>, HashSet<String>)> {
1462    let FetchPlanInput {
1463        advertisements,
1464        refspecs,
1465        options,
1466        store,
1467        reachable,
1468        local_db,
1469        deepen_excluded,
1470        format,
1471        configured_remote_fetch,
1472        has_merge_config,
1473        tracking_refspecs,
1474    } = input;
1475    let visible_advertisements = advertisements_without_peeled_refs(advertisements);
1476    let planning_advertisements = if visible_advertisements.len() == advertisements.len() {
1477        advertisements
1478    } else {
1479        visible_advertisements.as_slice()
1480    };
1481    let mut updates = plan_fetch_ref_updates(
1482        format,
1483        planning_advertisements,
1484        refspecs,
1485        options.auto_follow_tags,
1486    )?;
1487    if options.fetch_all_tags {
1488        mark_tag_refspec_updates_not_for_merge(&mut updates);
1489    } else {
1490        if options.auto_follow_tags
1491            && let Some((remote_db, advertisements)) = reachable
1492        {
1493            let visible_reachable_advertisements =
1494                advertisements_without_peeled_refs(advertisements);
1495            let reachable_advertisements =
1496                if visible_reachable_advertisements.len() == advertisements.len() {
1497                    advertisements
1498                } else {
1499                    visible_reachable_advertisements.as_slice()
1500                };
1501            append_reachable_auto_follow_tags(
1502                reachable_advertisements,
1503                remote_db,
1504                local_db,
1505                format,
1506                refspecs,
1507                &mut updates,
1508                deepen_excluded,
1509            )?;
1510        }
1511        retain_missing_auto_follow_tags(store, &mut updates)?;
1512    }
1513    if configured_remote_fetch || has_merge_config {
1514        for update in &mut updates {
1515            update.not_for_merge = true;
1516        }
1517        if !options.merge_srcs.is_empty() {
1518            // The current branch's `branch.<name>.merge` ref(s) are what we'll
1519            // merge, so they are the for-merge entries in FETCH_HEAD. Each entry
1520            // is matched with git's abbreviation rules (`branch_merge_matches`);
1521            // more than one is an octopus merge config.
1522            for update in &mut updates {
1523                if options
1524                    .merge_srcs
1525                    .iter()
1526                    .any(|src| refname_matches(src, &update.src))
1527                {
1528                    update.not_for_merge = false;
1529                }
1530            }
1531        } else if let Some(first) = refspecs.iter().find(|refspec| !refspec.negative)
1532            && !first.pattern
1533        {
1534            // No merge config: mirror git's get_ref_map default, which marks the
1535            // first matched ref of the first configured (non-pattern) fetch
1536            // refspec as for-merge. Pattern-led configs (e.g. refs/heads/*) leave
1537            // every entry not-for-merge.
1538            if let Some(update) = updates.first_mut() {
1539                update.not_for_merge = false;
1540            }
1541        }
1542        // git's store_updated_refs writes FETCH_HEAD in two passes: all for-merge
1543        // entries first (in ref-map order), then all not-for-merge. Reorder
1544        // stably to reproduce that layout.
1545        updates.sort_by_key(|update| update.not_for_merge);
1546    }
1547    let opportunistic_dsts =
1548        append_opportunistic_tracking_updates(&mut updates, tracking_refspecs)?;
1549    ref_remove_duplicate_updates(&mut updates)?;
1550    Ok((updates, opportunistic_dsts))
1551}
1552
1553/// Mirror git's `ref_remove_duplicates` (remote.c): two ref-map entries with the
1554/// same destination are collapsed when they came from the same source ref (e.g.
1555/// a remote that lists `+refs/heads/*:refs/remotes/origin/*` twice), and rejected
1556/// when two *different* sources would map to one destination.
1557fn ref_remove_duplicate_updates(updates: &mut Vec<FetchRefUpdate>) -> Result<()> {
1558    let mut seen: BTreeMap<String, String> = BTreeMap::new();
1559    let mut error = None;
1560    updates.retain(|update| {
1561        let Some(dst) = update.dst.as_deref() else {
1562            return true;
1563        };
1564        match seen.get(dst) {
1565            Some(prev_src) if prev_src == &update.src => false,
1566            Some(prev_src) => {
1567                if error.is_none() {
1568                    error = Some(GitError::Command(format!(
1569                        "Cannot fetch both {} and {} to {dst}",
1570                        prev_src, update.src
1571                    )));
1572                }
1573                true
1574            }
1575            None => {
1576                seen.insert(dst.to_string(), update.src.clone());
1577                true
1578            }
1579        }
1580    });
1581    match error {
1582        Some(err) => Err(err),
1583        None => Ok(()),
1584    }
1585}
1586
1587fn configured_refspecs_for_tracking(config: &GitConfig, remote: &str) -> Vec<String> {
1588    if remote_exists(config, remote) {
1589        remote_config_values(config, remote, "fetch")
1590    } else {
1591        Vec::new()
1592    }
1593}
1594
1595/// Append the opportunistic remote-tracking updates for a command-line refspec
1596/// fetch (a fetched ref that also matches a configured tracking refspec). Returns
1597/// the set of destinations these added — git marks them `FETCH_HEAD_IGNORE`, so
1598/// the caller excludes them from `FETCH_HEAD` while still applying them as refs.
1599fn append_opportunistic_tracking_updates(
1600    updates: &mut Vec<FetchRefUpdate>,
1601    tracking_refspecs: &[RefSpec],
1602) -> Result<HashSet<String>> {
1603    let mut opportunistic_dsts = HashSet::new();
1604    if tracking_refspecs.is_empty() {
1605        return Ok(opportunistic_dsts);
1606    }
1607    let mut seen_dsts = updates
1608        .iter()
1609        .filter_map(|update| update.dst.clone())
1610        .collect::<HashSet<_>>();
1611    let mut additions = Vec::new();
1612    for update in updates.iter() {
1613        if fetch_refspec_excludes(tracking_refspecs, &update.src)? {
1614            continue;
1615        }
1616        for refspec in tracking_refspecs.iter().filter(|refspec| !refspec.negative) {
1617            let Some(dst) = refspec_map_source(refspec, &update.src)? else {
1618                continue;
1619            };
1620            if !seen_dsts.insert(dst.clone()) {
1621                continue;
1622            }
1623            opportunistic_dsts.insert(dst.clone());
1624            additions.push(FetchRefUpdate {
1625                src: update.src.clone(),
1626                dst: Some(dst),
1627                oid: update.oid,
1628                not_for_merge: true,
1629                force: refspec.force,
1630            });
1631        }
1632    }
1633    updates.extend(additions);
1634    Ok(opportunistic_dsts)
1635}
1636
1637fn advertisements_without_peeled_refs(
1638    advertisements: &[RefAdvertisement],
1639) -> Vec<RefAdvertisement> {
1640    advertisements
1641        .iter()
1642        .filter(|advertisement| !advertisement.name.ends_with("^{}"))
1643        .cloned()
1644        .collect()
1645}
1646
1647fn append_missing_ext_advertised_tags(
1648    advertisements: &[RefAdvertisement],
1649    refspecs: &[RefSpec],
1650    store: &FileRefStore,
1651    updates: &mut Vec<FetchRefUpdate>,
1652) -> Result<()> {
1653    let mut seen = updates
1654        .iter()
1655        .map(|update| update.src.clone())
1656        .collect::<HashSet<_>>();
1657    let mut tags = Vec::new();
1658    for reference in advertisements {
1659        if !reference.name.starts_with("refs/tags/")
1660            || reference.name.ends_with("^{}")
1661            || !seen.insert(reference.name.clone())
1662            || fetch_refspec_excludes(refspecs, &reference.name)?
1663            || store.read_ref(&reference.name)?.is_some()
1664        {
1665            continue;
1666        }
1667        tags.push(FetchRefUpdate {
1668            src: reference.name.clone(),
1669            dst: Some(reference.name.clone()),
1670            oid: reference.oid,
1671            not_for_merge: true,
1672            force: false,
1673        });
1674    }
1675    tags.sort_by(|a, b| a.src.cmp(&b.src));
1676    updates.extend(tags);
1677    Ok(())
1678}
1679
1680/// Write `FETCH_HEAD`, apply the remote-tracking ref updates, and record the
1681/// applied updates in `outcome`. A no-op on `dry_run` (the pack is already
1682/// installed; refs and `FETCH_HEAD` are left untouched), matching the CLI.
1683struct FetchFinalize<'a> {
1684    git_dir: &'a Path,
1685    format: ObjectFormat,
1686    store: &'a FileRefStore,
1687    options: &'a FetchOptions,
1688    fetch_head_source: &'a str,
1689    default_head_fetch: bool,
1690    log_all_ref_updates: bool,
1691    ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
1692    /// Destinations of opportunistic tracking updates (git's `FETCH_HEAD_IGNORE`):
1693    /// applied as refs but excluded from `FETCH_HEAD`.
1694    opportunistic_dsts: &'a HashSet<String>,
1695    validation: Option<&'a sley_fsck::FsckPolicy>,
1696    quarantine: &'a mut Option<sley_odb::IncomingPackQuarantine>,
1697    /// Pending shallow boundary changes returned with the incoming pack. These
1698    /// must participate in quarantine validation: without them a deliberately
1699    /// truncated pack looks like it has a broken parent link.
1700    shallow_info: &'a [sley_protocol::ProtocolV2FetchShallowInfo],
1701}
1702
1703/// git's `store_updated_refs` (builtin/fetch.c) downgrades any for-merge
1704/// FETCH_HEAD entry whose object does not peel to a commit to not-for-merge: an
1705/// explicit `tag <name>` whose tag points at a tree or blob (e.g. `tag-one-tree`)
1706/// is recorded but never eligible for merge. Runs after the pack is installed so
1707/// the objects are present locally.
1708fn downgrade_non_commit_for_merge(
1709    db: &FileObjectDatabase,
1710    format: ObjectFormat,
1711    updates: &mut [FetchRefUpdate],
1712) {
1713    if updates.iter().all(|update| update.not_for_merge) {
1714        return;
1715    }
1716    for update in updates.iter_mut() {
1717        if !update.not_for_merge && sley_rev::peel_to_commit(db, format, &update.oid).is_err() {
1718            update.not_for_merge = true;
1719        }
1720    }
1721}
1722
1723fn finalize_fetch(
1724    finalize: FetchFinalize<'_>,
1725    updates: &mut Vec<FetchRefUpdate>,
1726    outcome: &mut FetchOutcome,
1727) -> Result<()> {
1728    let FetchFinalize {
1729        git_dir,
1730        format,
1731        store,
1732        options,
1733        fetch_head_source,
1734        default_head_fetch,
1735        log_all_ref_updates,
1736        ref_hook,
1737        opportunistic_dsts,
1738        validation,
1739        quarantine,
1740        shallow_info,
1741    } = finalize;
1742    // Incoming connectivity is defined against the boundary sent alongside
1743    // the pack. Stage that boundary only inside the quarantine first; a failed
1744    // validation then drops both objects and metadata without mutating the
1745    // destination repository.
1746    if let Some(incoming) = quarantine.as_ref() {
1747        crate::shallow::apply_shallow_info(incoming.git_dir(), format, shallow_info)?;
1748    }
1749    let main_db = FileObjectDatabase::from_git_dir(git_dir, format);
1750    let quarantine_db = quarantine
1751        .as_ref()
1752        .map(sley_odb::IncomingPackQuarantine::database);
1753    let validation_db = quarantine_db.as_ref().unwrap_or(&main_db);
1754    let unshallow_received = shallow_info.iter().any(|entry| {
1755        matches!(
1756            entry,
1757            sley_protocol::ProtocolV2FetchShallowInfo::Unshallow(_)
1758        )
1759    });
1760    validate_incoming_fetch_objects(
1761        validation_db,
1762        format,
1763        updates,
1764        validation,
1765        unshallow_received,
1766    )?;
1767    downgrade_non_commit_for_merge(validation_db, format, updates);
1768    // Once the incoming graph passes validation it is safe to expose the
1769    // objects. Git retains a successfully received pack even when a later ref
1770    // update is rejected, and the ref checks below must be able to walk the new
1771    // tips against the destination object database.
1772    if let Some(incoming) = quarantine.take() {
1773        incoming.promote()?;
1774    }
1775    if options.dry_run {
1776        outcome.ref_updates = std::mem::take(updates);
1777        return Ok(());
1778    }
1779    // Commit shallow metadata only after the incoming object graph validates.
1780    // This also keeps a failed fetch from leaving a boundary that names objects
1781    // which never escaped quarantine.
1782    crate::shallow::apply_shallow_info(git_dir, format, shallow_info)?;
1783    drop_redundant_symref_alias_updates(store, updates)?;
1784    validate_fetch_ref_updates(git_dir, format, store, options.update_head_ok, updates)?;
1785    if options.atomic {
1786        // Atomic fetch (`do_fetch`/`store_updated_refs` with a transaction): a
1787        // single rejected update aborts the whole fetch and leaves `FETCH_HEAD`
1788        // empty. git truncates `FETCH_HEAD` up front (unless `--append`) and
1789        // only re-writes the buffered records once the transaction commits, so
1790        // an abort leaves the truncated (empty) file. Reject non-fast-forward
1791        // tracking updates first, then apply every update in one transaction
1792        // (firing the `reference-transaction` hook, which may itself abort).
1793        if let Some(reason) = atomic_non_fast_forward_rejection(git_dir, format, store, updates)? {
1794            return Err(GitError::Command(reason));
1795        }
1796        if options.write_fetch_head && !options.append {
1797            fs::write(git_dir.join("FETCH_HEAD"), b"")?;
1798        }
1799        apply_fetch_ref_updates(
1800            store,
1801            format,
1802            fetch_head_source,
1803            log_all_ref_updates,
1804            updates,
1805            ref_hook,
1806        )?;
1807        if options.write_fetch_head {
1808            // Already truncated above when not appending, so always append the
1809            // committed records (mirrors git's buffer-then-`commit_fetch_head`).
1810            write_finalized_fetch_head(
1811                git_dir,
1812                fetch_head_source,
1813                default_head_fetch,
1814                updates,
1815                opportunistic_dsts,
1816                true,
1817            )?;
1818            outcome.wrote_fetch_head = true;
1819        }
1820        outcome.ref_updates = std::mem::take(updates);
1821        return Ok(());
1822    }
1823    let (rejected, rejection) =
1824        non_atomic_non_fast_forward_rejections(git_dir, format, store, updates)?;
1825    let accepted = updates
1826        .iter()
1827        .enumerate()
1828        .filter(|(index, _)| !rejected.contains(index))
1829        .map(|(_, update)| update.clone())
1830        .collect::<Vec<_>>();
1831    if accepted.is_empty()
1832        && let Some(reason) = rejection.as_ref()
1833    {
1834        return Err(GitError::Command(reason.clone()));
1835    }
1836    if options.write_fetch_head {
1837        write_finalized_fetch_head(
1838            git_dir,
1839            fetch_head_source,
1840            default_head_fetch,
1841            updates,
1842            opportunistic_dsts,
1843            options.append,
1844        )?;
1845        outcome.wrote_fetch_head = true;
1846    }
1847    apply_fetch_ref_updates(
1848        store,
1849        format,
1850        fetch_head_source,
1851        log_all_ref_updates,
1852        &accepted,
1853        ref_hook,
1854    )?;
1855    if let Some(reason) = rejection {
1856        return Err(GitError::Command(reason));
1857    }
1858    outcome.ref_updates = std::mem::take(updates);
1859    Ok(())
1860}
1861
1862fn validate_incoming_fetch_objects(
1863    db: &FileObjectDatabase,
1864    format: ObjectFormat,
1865    updates: &[FetchRefUpdate],
1866    policy: Option<&sley_fsck::FsckPolicy>,
1867    require_connectivity: bool,
1868) -> Result<()> {
1869    if policy.is_none() && !require_connectivity {
1870        return Ok(());
1871    }
1872    let mut seen = HashSet::new();
1873    let roots = updates
1874        .iter()
1875        .map(|update| update.oid)
1876        .filter(|oid| seen.insert(*oid))
1877        .collect::<Vec<_>>();
1878    if roots.is_empty() {
1879        return Ok(());
1880    }
1881    let options = policy.map_or_else(
1882        || sley_fsck::FsckOptions {
1883            connectivity_only: true,
1884            check_content: false,
1885            ..Default::default()
1886        },
1887        |policy| policy.fsck_options(policy.enabled),
1888    );
1889    let report = sley_fsck::fsck_objects_with_options(db, format, roots, [], options);
1890    for issue in &report.issues {
1891        match issue.stream {
1892            sley_fsck::IssueStream::Stdout => println!("{}", issue.message),
1893            sley_fsck::IssueStream::Stderr => eprintln!("{}", issue.message),
1894        }
1895    }
1896    if report.is_ok() {
1897        Ok(())
1898    } else {
1899        Err(GitError::Exit(128))
1900    }
1901}
1902
1903/// A wildcard fetch can map both a symbolic alias and its target, for example
1904/// `refs/remotes/origin/HEAD` and `refs/remotes/origin/main`. Updating the target
1905/// already updates what the alias resolves to; replacing the alias with a direct
1906/// ref would destroy the remote HEAD relationship. Drop only the redundant alias
1907/// entry when the same plan updates its target to the identical object id.
1908fn drop_redundant_symref_alias_updates(
1909    store: &FileRefStore,
1910    updates: &mut Vec<FetchRefUpdate>,
1911) -> Result<()> {
1912    let target_updates = updates
1913        .iter()
1914        .filter_map(|update| update.dst.clone().map(|dst| (dst, update.oid)))
1915        .collect::<Vec<_>>();
1916    let mut keep = Vec::with_capacity(updates.len());
1917    for update in updates.drain(..) {
1918        let redundant = match update.dst.as_deref() {
1919            Some(dst) => match store.read_ref(dst)? {
1920                Some(RefTarget::Symbolic(target)) => target_updates
1921                    .iter()
1922                    .any(|(candidate, oid)| candidate == &target && *oid == update.oid),
1923                _ => false,
1924            },
1925            None => false,
1926        };
1927        if !redundant {
1928            keep.push(update);
1929        }
1930    }
1931    *updates = keep;
1932    Ok(())
1933}
1934
1935/// Write `FETCH_HEAD` for the planned `updates`, using the bare-`HEAD` default
1936/// record when the fetch was a single default `HEAD` fetch. Opportunistic
1937/// tracking updates (git's `FETCH_HEAD_IGNORE`) are dropped — they are applied
1938/// as refs but not recorded in `FETCH_HEAD`.
1939fn write_finalized_fetch_head(
1940    git_dir: &Path,
1941    fetch_head_source: &str,
1942    default_head_fetch: bool,
1943    updates: &[FetchRefUpdate],
1944    opportunistic_dsts: &HashSet<String>,
1945    append: bool,
1946) -> Result<()> {
1947    if default_head_fetch
1948        && updates.len() == 1
1949        && updates[0].src == "HEAD"
1950        && updates[0].dst.is_none()
1951    {
1952        return write_default_fetch_head(git_dir, fetch_head_source, updates[0].oid, append);
1953    }
1954    let records: Vec<FetchRefUpdate> = updates
1955        .iter()
1956        .filter(|update| {
1957            update
1958                .dst
1959                .as_deref()
1960                .is_none_or(|dst| !opportunistic_dsts.contains(dst))
1961        })
1962        .cloned()
1963        .collect();
1964    write_fetch_head(git_dir, fetch_head_source, &records, append)
1965}
1966
1967/// Reject the first non-fast-forward tracking update an `--atomic` fetch would
1968/// make (a non-forced refspec whose destination already exists and whose new tip
1969/// does not descend from the old). Returns the git-shaped `! [rejected]` line so
1970/// the whole atomic transaction can be aborted before any ref is touched.
1971fn atomic_non_fast_forward_rejection(
1972    git_dir: &Path,
1973    format: ObjectFormat,
1974    store: &FileRefStore,
1975    updates: &[FetchRefUpdate],
1976) -> Result<Option<String>> {
1977    let mut db: Option<FileObjectDatabase> = None;
1978    for update in updates {
1979        let Some(dst) = update.dst.as_deref() else {
1980            continue;
1981        };
1982        if update.force {
1983            continue;
1984        }
1985        let Some(RefTarget::Direct(old)) = store.read_ref(dst)? else {
1986            continue;
1987        };
1988        if old == update.oid || dst.starts_with("refs/tags/") {
1989            continue;
1990        }
1991        let db = db.get_or_insert_with(|| FileObjectDatabase::from_git_dir(git_dir, format));
1992        if !crate::push::is_fast_forward(git_dir, db, format, &old, &update.oid)? {
1993            return Ok(Some(format!(
1994                "! [rejected]        {} -> {}  (non-fast-forward)",
1995                update.src, dst
1996            )));
1997        }
1998    }
1999    Ok(None)
2000}
2001
2002/// Find non-forced, non-fast-forward ref updates for a non-atomic fetch. Git
2003/// still records the advertised tips in `FETCH_HEAD` and applies unrelated
2004/// accepted updates, but leaves each rejected destination untouched and exits
2005/// unsuccessfully. Return both the rejected indices and the first diagnostic.
2006fn non_atomic_non_fast_forward_rejections(
2007    git_dir: &Path,
2008    format: ObjectFormat,
2009    store: &FileRefStore,
2010    updates: &[FetchRefUpdate],
2011) -> Result<(HashSet<usize>, Option<String>)> {
2012    let mut rejected = HashSet::new();
2013    let mut reason = None;
2014    let mut db: Option<FileObjectDatabase> = None;
2015    for (index, update) in updates.iter().enumerate() {
2016        let Some(dst) = update.dst.as_deref() else {
2017            continue;
2018        };
2019        if update.force || dst.starts_with("refs/tags/") {
2020            continue;
2021        }
2022        let Some(RefTarget::Direct(old)) = store.read_ref(dst)? else {
2023            continue;
2024        };
2025        if old == update.oid {
2026            continue;
2027        }
2028        let db = db.get_or_insert_with(|| FileObjectDatabase::from_git_dir(git_dir, format));
2029        if !crate::push::is_fast_forward(git_dir, db, format, &old, &update.oid)? {
2030            rejected.insert(index);
2031            reason.get_or_insert_with(|| {
2032                format!(
2033                    "! [rejected]        {} -> {}  (non-fast-forward)",
2034                    update.src, dst
2035                )
2036            });
2037        }
2038    }
2039    Ok((rejected, reason))
2040}
2041
2042fn apply_fetch_ref_updates(
2043    store: &FileRefStore,
2044    format: ObjectFormat,
2045    fetch_head_source: &str,
2046    log_all_ref_updates: bool,
2047    updates: &[FetchRefUpdate],
2048    ref_hook: Option<&dyn sley_refs::ReferenceTransactionHook>,
2049) -> Result<()> {
2050    let mut seen = BTreeSet::new();
2051    let mut tx = store.transaction();
2052    if let Some(hook) = ref_hook {
2053        tx = tx.with_hook(hook);
2054    }
2055    for update in updates {
2056        let Some(dst) = update.dst.as_deref() else {
2057            continue;
2058        };
2059        if !seen.insert(dst.to_string()) {
2060            return Err(GitError::Transaction(format!("duplicate fetch ref {dst}")));
2061        }
2062        let old_oid = match store.read_ref(dst)? {
2063            Some(RefTarget::Direct(oid)) => Some(oid),
2064            Some(RefTarget::Symbolic(target)) => {
2065                return Err(GitError::Transaction(format!(
2066                    "fetch ref {dst} would overwrite symbolic ref {target}"
2067                )));
2068            }
2069            None => None,
2070        };
2071        let reflog = if log_all_ref_updates && fetch_should_write_reflog(dst) {
2072            Some(ReflogEntry {
2073                old_oid: old_oid.unwrap_or_else(|| ObjectId::null(format)),
2074                new_oid: update.oid,
2075                committer: fetch_reflog_committer(),
2076                message: fetch_reflog_message(fetch_head_source, update, old_oid.is_some()),
2077            })
2078        } else {
2079            None
2080        };
2081        tx.update(RefUpdate {
2082            name: dst.to_string(),
2083            expected: old_oid.map(RefTarget::Direct),
2084            new: RefTarget::Direct(update.oid),
2085            reflog,
2086        });
2087    }
2088    tx.commit()
2089}
2090
2091/// Effective fetch pack input cap, mirroring git's `fetch.maxInputSize` with
2092/// `transfer.maxSize` as the fallback when the fetch-specific key is unset.
2093pub fn fetch_max_input_size(config: &GitConfig) -> Option<u64> {
2094    config_max_input_size(config, "fetch", "maxInputSize")
2095        .or_else(|| config_max_input_size(config, "transfer", "maxSize"))
2096}
2097
2098fn config_max_input_size(config: &GitConfig, section: &str, key: &str) -> Option<u64> {
2099    let raw = config.get(section, None, key)?;
2100    match sley_config::parse_config_int(raw) {
2101        Some(limit) if limit > 0 => Some(limit as u64),
2102        _ => None,
2103    }
2104}
2105
2106fn fetch_log_all_ref_updates(config: &GitConfig) -> bool {
2107    match config.get("core", None, "logallrefupdates") {
2108        Some(value) => {
2109            let value = value.to_ascii_lowercase();
2110            matches!(value.as_str(), "true" | "yes" | "on" | "1" | "always")
2111        }
2112        None => false,
2113    }
2114}
2115
2116fn fetch_should_write_reflog(refname: &str) -> bool {
2117    refname == "HEAD"
2118        || refname.starts_with("refs/heads/")
2119        || refname.starts_with("refs/remotes/")
2120        || refname.starts_with("refs/notes/")
2121}
2122
2123fn fetch_reflog_committer() -> Vec<u8> {
2124    let seconds = SystemTime::now()
2125        .duration_since(UNIX_EPOCH)
2126        .map(|duration| duration.as_secs())
2127        .unwrap_or(0);
2128    format!("Git Rs <sley@example.invalid> {seconds} +0000").into_bytes()
2129}
2130
2131fn fetch_reflog_message(source: &str, update: &FetchRefUpdate, old_exists: bool) -> Vec<u8> {
2132    let src = fetch_reflog_short_ref(&update.src);
2133    let dst = update
2134        .dst
2135        .as_deref()
2136        .map(fetch_reflog_short_ref)
2137        .unwrap_or_else(|| update.src.clone());
2138    let action = if !old_exists {
2139        if update.src.starts_with("refs/tags/") {
2140            "storing tag"
2141        } else if update.src.starts_with("refs/heads/") {
2142            "storing head"
2143        } else {
2144            "storing ref"
2145        }
2146    } else if update.force {
2147        "forced-update"
2148    } else if update.src.starts_with("refs/tags/") {
2149        "updating tag"
2150    } else {
2151        "fast-forward"
2152    };
2153    format!("fetch {source} {src}:{dst}: {action}").into_bytes()
2154}
2155
2156fn fetch_reflog_short_ref(refname: &str) -> String {
2157    for prefix in ["refs/heads/", "refs/tags/", "refs/remotes/"] {
2158        if let Some(short) = refname.strip_prefix(prefix) {
2159            return short.to_string();
2160        }
2161    }
2162    refname.to_string()
2163}
2164
2165fn validate_fetch_ref_updates(
2166    git_dir: &Path,
2167    _format: ObjectFormat,
2168    store: &FileRefStore,
2169    update_head_ok: bool,
2170    updates: &[FetchRefUpdate],
2171) -> Result<()> {
2172    for update in updates {
2173        let Some(dst) = update.dst.as_deref() else {
2174            continue;
2175        };
2176        let old = match store.read_ref(dst)? {
2177            Some(RefTarget::Direct(oid)) => Some(oid),
2178            Some(RefTarget::Symbolic(target)) => {
2179                return Err(GitError::Transaction(format!(
2180                    "ref {dst} would overwrite symbolic ref {target}"
2181                )));
2182            }
2183            None => None,
2184        };
2185        if old.is_some()
2186            && !update_head_ok
2187            && dst.starts_with("refs/heads/")
2188            && let Some(worktree) = sley_worktree::find_shared_symref(git_dir, "HEAD", dst)?
2189        {
2190            return Err(GitError::InvalidFormat(format!(
2191                "fatal: refusing to fetch into branch '{dst}' checked out at '{}'",
2192                worktree.path.display()
2193            )));
2194        }
2195        if old.is_some()
2196            && old != Some(update.oid)
2197            && dst.starts_with("refs/tags/")
2198            && !update.force
2199        {
2200            return Err(GitError::Command(format!(
2201                "! [rejected]        {} -> {}  (would clobber existing tag)",
2202                update.src, dst
2203            )));
2204        }
2205    }
2206    Ok(())
2207}
2208
2209/// The remote's advertised `HEAD` symref target (`HEAD:<target>` capability).
2210fn head_symref_from_features(symrefs: &[String]) -> Option<String> {
2211    symrefs
2212        .iter()
2213        .find_map(|entry| entry.strip_prefix("HEAD:").map(|target| target.to_string()))
2214}
2215
2216fn reject_shallow_clone_fetch(
2217    options: &FetchOptions,
2218    shallow_info: &[sley_protocol::ProtocolV2FetchShallowInfo],
2219) -> Result<()> {
2220    // Upstream fetch-pack sets `args->deepen` when ANY deepen argument is present
2221    // (depth, deepen-since, or deepen-not) and only dies on a shallow source when
2222    // no deepen was requested (the shallow lines are then attributed to the remote
2223    // being shallow, not to our own depth request). Mirror that gate here.
2224    let deepening =
2225        options.depth.is_some() || options.deepen_since.is_some() || !options.deepen_not.is_empty();
2226    if options.reject_shallow && options.cloning && !deepening && !shallow_info.is_empty() {
2227        eprintln!("fatal: source repository is shallow, reject to clone.");
2228        return Err(GitError::Exit(128));
2229    }
2230    Ok(())
2231}
2232
2233fn custom_negotiation_haves(
2234    git_dir: &Path,
2235    format: ObjectFormat,
2236    config: &GitConfig,
2237    remote: &str,
2238    options: &FetchOptions,
2239) -> Result<Option<Vec<ObjectId>>> {
2240    // The noop negotiator is an explicit request to advertise no local commits.
2241    // Preserve `Some(empty)` here: `None` means "use the transport's default
2242    // local haves", so collapsing the two would silently turn noop back into the
2243    // ordinary negotiator on every transport.
2244    if config
2245        .get("fetch", None, "negotiationalgorithm")
2246        .is_some_and(|value| value.eq_ignore_ascii_case("noop"))
2247    {
2248        return Ok(Some(Vec::new()));
2249    }
2250    let restrict = match &options.negotiation_restrict {
2251        Some(values) => values.clone(),
2252        None => configured_negotiation_values(config, remote, "negotiationrestrict"),
2253    };
2254    let include = match &options.negotiation_include {
2255        Some(values) => values.clone(),
2256        None => configured_negotiation_values(config, remote, "negotiationinclude"),
2257    };
2258    if restrict.is_empty() && include.is_empty() {
2259        return Ok(None);
2260    }
2261
2262    let store = FileRefStore::new(git_dir, format);
2263    let db = FileObjectDatabase::from_git_dir(git_dir, format);
2264    let mut seen = HashSet::new();
2265    let mut haves = Vec::new();
2266    if restrict.is_empty() {
2267        for oid in crate::local::local_have_oids(git_dir, format)? {
2268            push_have_oid(&mut haves, &mut seen, oid);
2269        }
2270    } else {
2271        for value in &restrict {
2272            for oid in resolve_negotiation_have_value(git_dir, format, &store, &db, value)? {
2273                push_have_oid(&mut haves, &mut seen, oid);
2274            }
2275        }
2276    }
2277    for value in &include {
2278        for oid in resolve_negotiation_have_value(git_dir, format, &store, &db, value)? {
2279            push_have_oid(&mut haves, &mut seen, oid);
2280        }
2281    }
2282    Ok(Some(haves))
2283}
2284
2285fn configured_negotiation_values(config: &GitConfig, remote: &str, key: &str) -> Vec<String> {
2286    let mut values = Vec::new();
2287    if !remote_exists(config, remote) {
2288        return values;
2289    }
2290    for value in remote_config_values(config, remote, key) {
2291        if value.is_empty() {
2292            values.clear();
2293        } else {
2294            values.push(value);
2295        }
2296    }
2297    values
2298}
2299
2300fn push_have_oid(haves: &mut Vec<ObjectId>, seen: &mut HashSet<ObjectId>, oid: ObjectId) {
2301    if seen.insert(oid) {
2302        haves.push(oid);
2303    }
2304}
2305
2306fn resolve_negotiation_have_value(
2307    git_dir: &Path,
2308    format: ObjectFormat,
2309    store: &FileRefStore,
2310    db: &FileObjectDatabase,
2311    value: &str,
2312) -> Result<Vec<ObjectId>> {
2313    if has_glob(value) {
2314        let mut out = Vec::new();
2315        for reference in store.list_refs()? {
2316            let RefTarget::Direct(oid) = reference.target else {
2317                continue;
2318            };
2319            if negotiation_pattern_matches(value, &reference.name) {
2320                out.push(oid);
2321            }
2322        }
2323        out.sort();
2324        out.dedup();
2325        return Ok(out);
2326    }
2327    if is_full_hex_oid(format, value) {
2328        let oid = ObjectId::from_hex(format, value)?;
2329        return if db.contains(&oid)? {
2330            Ok(vec![oid])
2331        } else {
2332            Err(GitError::InvalidFormat(format!(
2333                "fatal: the object {oid} does not exist"
2334            )))
2335        };
2336    }
2337    match sley_rev::resolve_revision(git_dir, format, value) {
2338        Ok(oid) => Ok(vec![oid]),
2339        Err(_) => Ok(Vec::new()),
2340    }
2341}
2342
2343fn has_glob(value: &str) -> bool {
2344    value
2345        .as_bytes()
2346        .iter()
2347        .any(|byte| matches!(byte, b'*' | b'?'))
2348}
2349
2350fn is_full_hex_oid(format: ObjectFormat, value: &str) -> bool {
2351    value.len() == format.hex_len() && value.as_bytes().iter().all(u8::is_ascii_hexdigit)
2352}
2353
2354fn negotiation_pattern_matches(pattern: &str, refname: &str) -> bool {
2355    glob_match(pattern, refname)
2356        || ["refs/heads/", "refs/tags/", "refs/remotes/"]
2357            .iter()
2358            .filter_map(|prefix| refname.strip_prefix(prefix))
2359            .any(|short| glob_match(pattern, short))
2360}
2361
2362fn glob_match(pattern: &str, text: &str) -> bool {
2363    glob_match_bytes(pattern.as_bytes(), text.as_bytes())
2364}
2365
2366fn glob_match_bytes(pattern: &[u8], text: &[u8]) -> bool {
2367    let (mut p, mut t) = (0, 0);
2368    let mut star = None;
2369    let mut match_after_star = 0;
2370    while t < text.len() {
2371        if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == text[t]) {
2372            p += 1;
2373            t += 1;
2374        } else if p < pattern.len() && pattern[p] == b'*' {
2375            star = Some(p);
2376            p += 1;
2377            match_after_star = t;
2378        } else if let Some(star_pos) = star {
2379            p = star_pos + 1;
2380            match_after_star += 1;
2381            t = match_after_star;
2382        } else {
2383            return false;
2384        }
2385    }
2386    while p < pattern.len() && pattern[p] == b'*' {
2387        p += 1;
2388    }
2389    p == pattern.len()
2390}
2391
2392/// Apply `remote.<name>.partialclonefilter` when `remote.<name>.promisor` is set.
2393pub fn apply_configured_partial_clone_filter(
2394    config: &GitConfig,
2395    remote: &str,
2396    options: &mut FetchOptions,
2397) {
2398    if config
2399        .get_bool("remote", Some(remote), "promisor")
2400        .unwrap_or(false)
2401        && let Some(filter) = config.get("remote", Some(remote), "partialclonefilter")
2402    {
2403        options.filter_auto = filter.eq_ignore_ascii_case("auto");
2404        options.filter = if options.filter_auto {
2405            None
2406        } else {
2407            crate::pack_filter_from_spec(filter)
2408        };
2409    }
2410}
2411
2412/// Apply the configured `remote.<name>.tagopt` unless the tag option was set
2413/// explicitly on the command line.
2414pub fn apply_configured_remote_tag_option(
2415    config: &GitConfig,
2416    source: &str,
2417    options: &mut FetchOptions,
2418) {
2419    if options.tag_option_explicit || !remote_exists(config, source) {
2420        return;
2421    }
2422    match remote_config_values(config, source, "tagopt")
2423        .into_iter()
2424        .last()
2425        .as_deref()
2426    {
2427        Some("--tags") => {
2428            options.auto_follow_tags = true;
2429            options.fetch_all_tags = true;
2430        }
2431        Some("--no-tags") => {
2432            options.auto_follow_tags = false;
2433            options.fetch_all_tags = false;
2434        }
2435        _ => {}
2436    }
2437}
2438
2439/// Apply the configured `remote.<name>.prune` (then `fetch.prune`) unless the
2440/// prune option was set explicitly on the command line.
2441pub fn apply_configured_fetch_prune_option(
2442    config: &GitConfig,
2443    source: &str,
2444    options: &mut FetchOptions,
2445) {
2446    if !options.prune_option_explicit {
2447        if let Some(prune) = config.get_bool("remote", Some(source), "prune") {
2448            options.prune = prune;
2449        } else if let Some(prune) = config.get_bool("fetch", None, "prune") {
2450            options.prune = prune;
2451        }
2452    }
2453    if !options.prune_tags_option_explicit {
2454        if let Some(prune_tags) = config.get_bool("remote", Some(source), "prunetags") {
2455            options.prune_tags = prune_tags;
2456        } else if let Some(prune_tags) = config.get_bool("fetch", None, "prunetags") {
2457            options.prune_tags = prune_tags;
2458        }
2459    }
2460}
2461
2462/// The effective refspec list for a fetch: explicit `refspecs`, else the
2463/// `configured` remote refspecs, else `HEAD`; with `refs/tags/*` appended when
2464/// fetching all tags.
2465pub fn fetch_refspecs_for_source(
2466    configured: Vec<String>,
2467    refspecs: &[String],
2468    fetch_all_tags: bool,
2469) -> Vec<String> {
2470    let mut effective = if !refspecs.is_empty() {
2471        refspecs.to_vec()
2472    } else if configured.is_empty() {
2473        vec!["HEAD".to_string()]
2474    } else {
2475        configured
2476    };
2477    if fetch_all_tags {
2478        effective.push("refs/tags/*:refs/tags/*".to_string());
2479    }
2480    effective
2481}
2482
2483fn prune_refspecs_for_source(
2484    configured: &[String],
2485    refspecs: &[String],
2486    prune_tags: bool,
2487) -> Vec<String> {
2488    let mut effective = if !refspecs.is_empty() {
2489        refspecs.to_vec()
2490    } else {
2491        configured.to_vec()
2492    };
2493    if prune_tags && refspecs.is_empty() {
2494        effective.push("refs/tags/*:refs/tags/*".to_string());
2495    }
2496    effective
2497}
2498
2499/// Whether a refspec (with source `src`) already covers `merge_src` — the test
2500/// `add_merge_config` makes before fetching a `branch.<x>.merge` ref separately.
2501/// A pattern source (`refs/heads/*`) covers any ref whose name fits the
2502/// prefix/suffix; a literal source matches by git's abbreviated `refname_match`.
2503fn refspec_source_covers(refspec: &RefSpec, src: &str, merge_src: &str) -> bool {
2504    if refspec.pattern {
2505        let Some((prefix, suffix)) = src.split_once('*') else {
2506            return false;
2507        };
2508        // A `branch.<x>.merge` value may be abbreviated (`two` for
2509        // `refs/heads/two`); git's `refname_match` resolves it against the
2510        // ref-map entry the glob produced. Test the merge ref both verbatim and
2511        // qualified under `refs/heads/`, the namespace branch merges live in.
2512        let fits = |name: &str| {
2513            name.len() >= prefix.len() + suffix.len()
2514                && name.starts_with(prefix)
2515                && name.ends_with(suffix)
2516        };
2517        fits(merge_src) || fits(&format!("refs/heads/{merge_src}"))
2518    } else {
2519        refname_matches(merge_src, src) || refname_matches(src, merge_src)
2520    }
2521}
2522
2523/// Mark tag refspec updates (`refs/tags/X:refs/tags/X`) as not-for-merge.
2524pub fn mark_tag_refspec_updates_not_for_merge(updates: &mut [FetchRefUpdate]) {
2525    for update in updates {
2526        if update.src.starts_with("refs/tags/") && update.dst.as_deref() == Some(&update.src) {
2527            update.not_for_merge = true;
2528        }
2529    }
2530}
2531
2532/// Drop auto-followed tags that already exist locally, keeping only missing ones.
2533pub fn retain_missing_auto_follow_tags(
2534    store: &FileRefStore,
2535    updates: &mut Vec<FetchRefUpdate>,
2536) -> Result<()> {
2537    let mut retained = Vec::with_capacity(updates.len());
2538    for update in updates.drain(..) {
2539        if update.not_for_merge
2540            && update.src.starts_with("refs/tags/")
2541            && update.dst.as_deref() == Some(&update.src)
2542            && store.read_ref(&update.src)?.is_some()
2543        {
2544            continue;
2545        }
2546        retained.push(update);
2547    }
2548    *updates = retained;
2549    Ok(())
2550}
2551
2552/// Append tags reachable from the fetched (non-tag) commits, using the remote
2553/// object database to test reachability.
2554pub fn append_reachable_auto_follow_tags(
2555    advertisements: &[RefAdvertisement],
2556    remote_db: &FileObjectDatabase,
2557    local_db: Option<&FileObjectDatabase>,
2558    format: ObjectFormat,
2559    refspecs: &[RefSpec],
2560    updates: &mut Vec<FetchRefUpdate>,
2561    deepen_excluded: Option<&HashSet<ObjectId>>,
2562) -> Result<()> {
2563    if !updates.iter().any(|update| update.dst.is_some()) {
2564        return Ok(());
2565    }
2566    // Drop any auto-follow tag entries the shared planner added: when we have the
2567    // remote object database we are the authoritative tag follower (we peel
2568    // annotated tags) and we re-add the full set sorted by refname, mirroring
2569    // git's `find_non_local_tags`, which inserts into a sorted string-list.
2570    updates.retain(|update| {
2571        !(update.src.starts_with("refs/tags/")
2572            && update.dst.as_deref() == Some(update.src.as_str())
2573            && update.not_for_merge)
2574    });
2575    // Reachability seeds are every object we're fetching (git's `fetch_oids`):
2576    // non-tag tips directly, and tag updates by their peeled target so an
2577    // explicitly-requested `tag <name>` still seeds the auto-follow of its
2578    // siblings.
2579    let mut starts = Vec::new();
2580    for update in updates.iter().filter(|update| update.dst.is_some()) {
2581        if update.src.starts_with("refs/tags/") {
2582            if let Some(target) = peel_tag_target(remote_db, format, &update.oid)? {
2583                starts.push(target);
2584            } else {
2585                starts.push(update.oid);
2586            }
2587        } else {
2588            starts.push(update.oid);
2589        }
2590    }
2591    // A deepen fetch must not auto-follow tags past the shallow boundary: only
2592    // tags whose target lands in the truncated pack are followed (upstream's
2593    // include-tag packs a tag only when its referenced object is packed).
2594    let reachable = match deepen_excluded {
2595        Some(excluded) => {
2596            collect_reachable_object_ids_excluding(remote_db, format, starts, excluded)?
2597        }
2598        None => {
2599            collect_reachable_object_ids_tolerating_promised_missing(remote_db, format, starts)?
2600        }
2601    };
2602    let fetched_srcs = updates
2603        .iter()
2604        .map(|update| update.src.clone())
2605        .collect::<HashSet<_>>();
2606    let mut followed = Vec::new();
2607    for reference in advertisements {
2608        if !reference.name.starts_with("refs/tags/")
2609            || fetched_srcs.contains(&reference.name)
2610            || fetch_refspec_excludes(refspecs, &reference.name)?
2611        {
2612            continue;
2613        }
2614        // A tag is auto-followed when the object it ultimately points at is
2615        // either among the objects being fetched (reachable from a fetched tip)
2616        // or already present in the local object database (git's
2617        // `find_non_local_tags`: `oidset_contains(fetch_oids) || odb_has_object`).
2618        // For lightweight tags the target is the advertised oid; for annotated
2619        // tags it is the peeled target (the tag object is never reachable from a
2620        // commit, so peel through the chain).
2621        let target = peel_tag_target(remote_db, format, &reference.oid)?.unwrap_or(reference.oid);
2622        let fetched = reachable.contains(&reference.oid) || reachable.contains(&target);
2623        let present_locally = local_db
2624            .map(|db| db.contains(&target))
2625            .transpose()?
2626            .unwrap_or(false);
2627        if !fetched && !present_locally {
2628            continue;
2629        }
2630        followed.push(FetchRefUpdate {
2631            src: reference.name.clone(),
2632            dst: Some(reference.name.clone()),
2633            oid: reference.oid,
2634            not_for_merge: true,
2635            force: false,
2636        });
2637    }
2638    followed.sort_by(|a, b| a.src.cmp(&b.src));
2639    updates.extend(followed);
2640    Ok(())
2641}
2642
2643/// Peel an annotated-tag object to the non-tag object it ultimately references,
2644/// following nested tag chains. Returns `None` if `oid` is not an annotated tag
2645/// (a lightweight tag points directly at its target, already the advertised oid)
2646/// or cannot be read from `db`.
2647fn peel_tag_target(
2648    db: &FileObjectDatabase,
2649    format: ObjectFormat,
2650    oid: &ObjectId,
2651) -> Result<Option<ObjectId>> {
2652    let mut current = *oid;
2653    let mut peeled = None;
2654    loop {
2655        let Ok(object) = db.read_object(&current) else {
2656            return Ok(peeled);
2657        };
2658        if object.object_type != sley_object::ObjectType::Tag {
2659            return Ok(peeled);
2660        }
2661        let tag = sley_object::Tag::parse(format, &object.body)?;
2662        current = tag.object;
2663        peeled = Some(current);
2664    }
2665}
2666
2667/// Whether any negative refspec excludes `name`.
2668pub fn fetch_refspec_excludes(refspecs: &[RefSpec], name: &str) -> Result<bool> {
2669    for refspec in refspecs.iter().filter(|refspec| refspec.negative) {
2670        if refspec.pattern {
2671            if refspec_map_source(refspec, name)?.is_some() {
2672                return Ok(true);
2673            }
2674        } else if refspec.src.as_deref() == Some(name) {
2675            return Ok(true);
2676        }
2677    }
2678    Ok(false)
2679}
2680
2681/// Reorder updates so a bundle `--tags` fetch lists non-tags, then tags pointing
2682/// at fetched commits, then the remaining tags (matching git's ordering).
2683pub fn order_bundle_fetch_all_tags_updates(updates: &mut Vec<FetchRefUpdate>) {
2684    let followed_oids = updates
2685        .iter()
2686        .filter(|update| !update.src.starts_with("refs/tags/") && update.dst.is_some())
2687        .map(|update| update.oid)
2688        .collect::<HashSet<_>>();
2689    if followed_oids.is_empty() {
2690        return;
2691    }
2692
2693    let mut non_tags = Vec::new();
2694    let mut followed_tags = Vec::new();
2695    let mut other_tags = Vec::new();
2696    for update in updates.drain(..) {
2697        if update.src.starts_with("refs/tags/") {
2698            if followed_oids.contains(&update.oid) {
2699                followed_tags.push(update);
2700            } else {
2701                other_tags.push(update);
2702            }
2703        } else {
2704            non_tags.push(update);
2705        }
2706    }
2707    updates.extend(non_tags);
2708    updates.extend(followed_tags);
2709    updates.extend(other_tags);
2710}
2711
2712/// Write a single default `FETCH_HEAD` record (a bare `HEAD` fetch).
2713pub fn write_default_fetch_head(
2714    git_dir: &Path,
2715    source: &str,
2716    oid: ObjectId,
2717    append: bool,
2718) -> Result<()> {
2719    let records = [FetchHeadRecord {
2720        oid,
2721        not_for_merge: false,
2722        description: source.to_string(),
2723    }];
2724    write_fetch_head_records(git_dir, &records, append)?;
2725    Ok(())
2726}
2727
2728/// Write `FETCH_HEAD` records, truncating or appending per `append`.
2729pub fn write_fetch_head_records(
2730    git_dir: &Path,
2731    records: &[FetchHeadRecord],
2732    append: bool,
2733) -> Result<()> {
2734    let encoded = encode_fetch_head(records)?;
2735    if append {
2736        let mut file = fs::OpenOptions::new()
2737            .create(true)
2738            .append(true)
2739            .open(git_dir.join("FETCH_HEAD"))?;
2740        file.write_all(&encoded)?;
2741    } else {
2742        fs::write(git_dir.join("FETCH_HEAD"), encoded)?;
2743    }
2744    Ok(())
2745}
2746
2747/// Write `FETCH_HEAD` from fetched ref updates, describing each by `description`.
2748pub fn write_fetch_head(
2749    git_dir: &Path,
2750    description: &str,
2751    fetched: &[FetchRefUpdate],
2752    append: bool,
2753) -> Result<()> {
2754    let records = fetch_ref_updates_to_fetch_head(fetched, description)?;
2755    write_fetch_head_records(git_dir, &records, append)?;
2756    Ok(())
2757}
2758
2759/// The `FETCH_HEAD` source description for `source`: its configured URL (rewritten
2760/// per `url.<base>.insteadOf`) if any, otherwise the rewritten `source`.
2761pub fn fetch_head_source_description(config: &GitConfig, source: &str) -> String {
2762    let url = remote_config_values(config, source, "url")
2763        .into_iter()
2764        .next()
2765        .map(|url| rewrite_url_with_config(config, &url, false))
2766        .unwrap_or_else(|| rewrite_url_with_config(config, source, false));
2767    redact_url_for_display(&trim_fetch_head_display_url(&url))
2768}
2769
2770/// Mirror git's `display_state` URL trimming (builtin/fetch.c): strip trailing
2771/// slashes and a trailing `.git` so the `FETCH_HEAD` note reads `branch 'x' of
2772/// ../` rather than `branch 'x' of ../.git/`.
2773fn trim_fetch_head_display_url(url: &str) -> String {
2774    let bytes = url.as_bytes();
2775    let mut end = bytes.len();
2776    while end > 0 && bytes[end - 1] == b'/' {
2777        end -= 1;
2778    }
2779    // `end` is the length excluding trailing slashes; git's `i` (index of the
2780    // last non-slash byte) is `end - 1`, and it strips `.git` only when `i > 4`.
2781    if end > 5 && &bytes[end - 4..end] == b".git" {
2782        end -= 4;
2783    }
2784    String::from_utf8_lossy(&bytes[..end]).into_owned()
2785}
2786
2787/// Prune refs whose destinations are covered by the active fetch refspecs and
2788/// whose corresponding remote sources are absent from `advertisements`,
2789/// deleting them and emitting git's notice lines through `progress` (unless
2790/// `quiet`). Returns the refs that were pruned.
2791pub struct PruneRefsInput<'a> {
2792    pub config: &'a GitConfig,
2793    pub store: &'a FileRefStore,
2794    pub remote: &'a str,
2795    pub advertisements: &'a [RefAdvertisement],
2796    pub refspecs: &'a [RefSpec],
2797    pub dry_run: bool,
2798    pub quiet: bool,
2799}
2800
2801pub fn prune_refs_from_advertisements(
2802    input: PruneRefsInput<'_>,
2803    progress: &mut dyn ProgressSink,
2804) -> Result<Vec<PrunedRef>> {
2805    let remote_refs = input
2806        .advertisements
2807        .iter()
2808        .filter(|advertisement| !advertisement.name.ends_with("^{}"))
2809        .map(|advertisement| advertisement.name.as_str())
2810        .collect::<BTreeSet<_>>();
2811    let local_refs = input.store.list_refs()?;
2812    let stale_refs = stale_refs_for_prune(&local_refs, input.refspecs, &remote_refs)?;
2813    if stale_refs.is_empty() {
2814        return Ok(Vec::new());
2815    }
2816    let mut emit = |line: &str| {
2817        if !input.quiet {
2818            progress.message(line);
2819        }
2820    };
2821    let display_url = redact_url_for_display(
2822        &remote_config_values(input.config, input.remote, "url")
2823            .into_iter()
2824            .next()
2825            .unwrap_or_else(|| input.remote.into()),
2826    );
2827    emit(&format!("Pruning {}", input.remote));
2828    emit(&format!("URL: {display_url}"));
2829    let mut pruned = Vec::new();
2830    for refname in stale_refs {
2831        if !input.dry_run {
2832            match input.store.read_ref(&refname)? {
2833                Some(RefTarget::Symbolic(_)) => {
2834                    let _ = input.store.delete_symbolic_ref(&refname)?;
2835                }
2836                Some(RefTarget::Direct(_)) => {
2837                    let _ = input.store.delete_ref(&refname)?;
2838                }
2839                None => {}
2840            }
2841        }
2842        let display = prettify_pruned_ref(input.remote, &refname);
2843        let action = if input.dry_run {
2844            "would prune"
2845        } else {
2846            "pruned"
2847        };
2848        emit(&format!(" * [{action}] {display}"));
2849        let branch = display;
2850        pruned.push(PrunedRef { branch, refname });
2851    }
2852    Ok(pruned)
2853}
2854
2855fn stale_refs_for_prune(
2856    local_refs: &[Ref],
2857    refspecs: &[RefSpec],
2858    remote_refs: &BTreeSet<&str>,
2859) -> Result<Vec<String>> {
2860    let mut stale = Vec::new();
2861    for reference in local_refs {
2862        if matches!(reference.target, RefTarget::Symbolic(_)) {
2863            continue;
2864        }
2865        let sources = prune_sources_for_destination(refspecs, &reference.name)?;
2866        if sources.is_empty() {
2867            continue;
2868        }
2869        if sources
2870            .iter()
2871            .all(|source| !remote_refs.contains(source.as_str()))
2872        {
2873            stale.push(reference.name.clone());
2874        }
2875    }
2876    stale.sort();
2877    Ok(stale)
2878}
2879
2880fn prune_sources_for_destination(refspecs: &[RefSpec], destination: &str) -> Result<Vec<String>> {
2881    let mut sources = Vec::new();
2882    for refspec in refspecs.iter().filter(|refspec| !refspec.negative) {
2883        let Some(src) = refspec.src.as_deref() else {
2884            continue;
2885        };
2886        let Some(dst) = refspec.dst.as_deref() else {
2887            continue;
2888        };
2889        if refspec.pattern {
2890            let Some((dst_prefix, dst_suffix)) = dst.split_once('*') else {
2891                continue;
2892            };
2893            let Some(middle) = destination
2894                .strip_prefix(dst_prefix)
2895                .and_then(|value| value.strip_suffix(dst_suffix))
2896            else {
2897                continue;
2898            };
2899            let (src_prefix, src_suffix) = src.split_once('*').ok_or_else(|| {
2900                GitError::InvalidFormat("pattern refspec source is missing wildcard".into())
2901            })?;
2902            sources.push(format!("{src_prefix}{middle}{src_suffix}"));
2903        } else if dst == destination {
2904            sources.push(src.to_string());
2905        }
2906    }
2907    sources.sort();
2908    sources.dedup();
2909    Ok(sources)
2910}
2911
2912fn prettify_pruned_ref(remote: &str, refname: &str) -> String {
2913    if let Some(branch) = refname.strip_prefix(&format!("refs/remotes/{remote}/")) {
2914        return format!("{remote}/{branch}");
2915    }
2916    if let Some(tag) = refname.strip_prefix("refs/tags/") {
2917        return tag.to_string();
2918    }
2919    refname.to_string()
2920}
2921
2922#[cfg(test)]
2923mod tests {
2924    use super::*;
2925    use std::sync::atomic::{AtomicU64, Ordering};
2926
2927    use sley_config::{ConfigEntry, ConfigSection};
2928    use sley_formats::RepositoryLayout;
2929    use sley_object::{Commit, EncodedObject, ObjectType, Tree};
2930    use sley_odb::{FileObjectDatabase, ObjectWriter};
2931    use sley_refs::{RefTarget, RefUpdate};
2932
2933    use crate::{NoCredentials, SilentProgress};
2934
2935    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
2936
2937    #[test]
2938    fn repository_plan_uses_injected_branch_snapshot() {
2939        let config = GitConfig {
2940            preamble: Vec::new(),
2941            suffix: Vec::new(),
2942            sections: vec![
2943                ConfigSection::new(
2944                    "remote",
2945                    Some("upstream".into()),
2946                    vec![ConfigEntry::new("url", Some("../upstream".into()))],
2947                ),
2948                ConfigSection::new(
2949                    "branch",
2950                    Some("topic".into()),
2951                    vec![
2952                        ConfigEntry::new("remote", Some("upstream".into())),
2953                        ConfigEntry::new("merge", Some("refs/heads/topic".into())),
2954                        ConfigEntry::new("merge", Some("refs/heads/next".into())),
2955                    ],
2956                ),
2957            ],
2958        };
2959        let plan = plan_fetch_repository(&config, Some("topic"), None);
2960        assert_eq!(plan.remote, "upstream");
2961        assert_eq!(plan.merge_srcs, ["refs/heads/topic", "refs/heads/next"]);
2962
2963        let explicit = plan_fetch_repository(&config, Some("topic"), Some("other"));
2964        assert_eq!(explicit.remote, "other");
2965        assert!(explicit.merge_srcs.is_empty());
2966    }
2967
2968    fn temp_repo(name: &str) -> PathBuf {
2969        let dir = std::env::temp_dir().join(format!(
2970            "sley-remote-fetch-{name}-{}-{}",
2971            std::process::id(),
2972            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
2973        ));
2974        let _ = fs::remove_dir_all(&dir);
2975        RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
2976            .expect("test repository should initialize");
2977        dir.join(".git")
2978    }
2979
2980    fn commit_on(git_dir: &Path, branch: &str, message: &str) -> ObjectId {
2981        let format = ObjectFormat::Sha1;
2982        let db = FileObjectDatabase::from_git_dir(git_dir, format);
2983        let tree = db
2984            .write_object(EncodedObject::new(
2985                ObjectType::Tree,
2986                Tree { entries: vec![] }.write(),
2987            ))
2988            .expect("tree should write");
2989        let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
2990        let oid = db
2991            .write_object(EncodedObject::new(
2992                ObjectType::Commit,
2993                Commit {
2994                    tree,
2995                    parents: Vec::new(),
2996                    author: identity.clone(),
2997                    committer: identity,
2998                    encoding: None,
2999                    message: format!("{message}\n").into_bytes(),
3000                }
3001                .write(),
3002            ))
3003            .expect("commit should write");
3004        let store = FileRefStore::new(git_dir, format);
3005        let mut tx = store.transaction();
3006        tx.update(RefUpdate {
3007            name: format!("refs/heads/{branch}"),
3008            expected: None,
3009            new: RefTarget::Direct(oid),
3010            reflog: None,
3011        });
3012        tx.update(RefUpdate {
3013            name: "HEAD".into(),
3014            expected: None,
3015            new: RefTarget::Symbolic(format!("refs/heads/{branch}")),
3016            reflog: None,
3017        });
3018        tx.commit().expect("refs should update");
3019        oid
3020    }
3021
3022    fn commit_child_on(git_dir: &Path, branch: &str, parent: ObjectId, message: &str) -> ObjectId {
3023        let format = ObjectFormat::Sha1;
3024        let db = FileObjectDatabase::from_git_dir(git_dir, format);
3025        let tree = db
3026            .write_object(EncodedObject::new(
3027                ObjectType::Tree,
3028                Tree { entries: vec![] }.write(),
3029            ))
3030            .expect("tree should write");
3031        let identity = b"Test User <test@example.invalid> 2 +0000".to_vec();
3032        let oid = db
3033            .write_object(EncodedObject::new(
3034                ObjectType::Commit,
3035                Commit {
3036                    tree,
3037                    parents: vec![parent],
3038                    author: identity.clone(),
3039                    committer: identity,
3040                    encoding: None,
3041                    message: format!("{message}\n").into_bytes(),
3042                }
3043                .write(),
3044            ))
3045            .expect("child commit should write");
3046        let store = FileRefStore::new(git_dir, format);
3047        let mut tx = store.transaction();
3048        tx.update(RefUpdate {
3049            name: format!("refs/heads/{branch}"),
3050            expected: None,
3051            new: RefTarget::Direct(oid),
3052            reflog: None,
3053        });
3054        tx.commit().expect("branch should update");
3055        oid
3056    }
3057
3058    fn default_options() -> FetchOptions {
3059        FetchOptions {
3060            quiet: true,
3061            progress: None,
3062            auto_follow_tags: false,
3063            fetch_all_tags: false,
3064            prune: false,
3065            prune_tags: false,
3066            dry_run: false,
3067            force: false,
3068            append: false,
3069            write_fetch_head: true,
3070            tag_option_explicit: true,
3071            prune_option_explicit: true,
3072            prune_tags_option_explicit: true,
3073            refmap: None,
3074            depth: None,
3075            merge_srcs: Vec::new(),
3076            filter: None,
3077            filter_auto: false,
3078            refetch: false,
3079            cloning: false,
3080            record_promisor_refs: true,
3081            update_shallow: false,
3082            reject_shallow: false,
3083            deepen_relative: false,
3084            update_head_ok: false,
3085            deepen_since: None,
3086            deepen_not: Vec::new(),
3087            ssh_options: None,
3088            upload_pack_command: None,
3089            atomic: false,
3090            negotiation_restrict: None,
3091            negotiation_include: None,
3092        }
3093    }
3094
3095    #[derive(Default)]
3096    struct RecordingProgress {
3097        transfers: Vec<PackGenerationProgress>,
3098    }
3099
3100    impl ProgressSink for RecordingProgress {
3101        fn pack_generation(&mut self, progress: &PackGenerationProgress) {
3102            self.transfers.push(*progress);
3103        }
3104    }
3105
3106    #[test]
3107    fn local_fetch_returns_and_emits_truthful_transfer_counts() {
3108        let remote = temp_repo("remote-transfer-progress");
3109        let local = temp_repo("local-transfer-progress");
3110        let remote_tip = commit_on(&remote, "main", "remote tip");
3111        let source = FetchSource::Local {
3112            git_dir: remote.clone(),
3113            common_git_dir: remote.clone(),
3114        };
3115        let refspecs = vec!["refs/heads/main:refs/remotes/origin/main".to_string()];
3116        let mut options = default_options();
3117        options.quiet = false;
3118        options.progress = Some(true);
3119        let mut credentials = NoCredentials;
3120        let mut progress = RecordingProgress::default();
3121
3122        let outcome = fetch(
3123            FetchRequest {
3124                git_dir: &local,
3125                format: ObjectFormat::Sha1,
3126                config: &GitConfig::default(),
3127                remote_name: "origin",
3128                source: &source,
3129                refspecs: &refspecs,
3130                options: &options,
3131                validation: None,
3132            },
3133            FetchServices {
3134                credentials: &mut credentials,
3135                progress: &mut progress,
3136                ref_hook: None,
3137            },
3138        )
3139        .expect("fetch should succeed");
3140
3141        let transfer = outcome
3142            .pack_generation_progress
3143            .expect("non-empty local fetch should report equal-work counts");
3144        assert_eq!(
3145            transfer,
3146            PackGenerationProgress {
3147                total_objects: 2,
3148                compression_objects: 1,
3149                delta_objects: 0,
3150            }
3151        );
3152        assert_eq!(progress.transfers, vec![transfer]);
3153        assert!(
3154            FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1)
3155                .contains(&remote_tip)
3156                .expect("read fetched commit")
3157        );
3158    }
3159
3160    #[test]
3161    fn noop_negotiator_selects_an_explicit_empty_have_set() {
3162        let local = temp_repo("noop-negotiator");
3163        let config = GitConfig::parse(b"[fetch]\n\tnegotiationAlgorithm = noop\n")
3164            .expect("config should parse");
3165        assert_eq!(
3166            custom_negotiation_haves(
3167                &local,
3168                ObjectFormat::Sha1,
3169                &config,
3170                "origin",
3171                &default_options(),
3172            )
3173            .expect("negotiation haves"),
3174            Some(Vec::new())
3175        );
3176    }
3177
3178    #[test]
3179    fn non_atomic_fetch_rejects_non_fast_forward_and_force_updates_it() {
3180        let remote = temp_repo("remote-non-fast-forward");
3181        let local = temp_repo("local-non-fast-forward");
3182        let remote_tip = commit_on(&remote, "main", "remote tip");
3183        let local_tip = commit_on(&local, "side", "local side");
3184        let source = FetchSource::Local {
3185            git_dir: remote.clone(),
3186            common_git_dir: remote.clone(),
3187        };
3188        let refspecs = vec!["refs/heads/main:refs/heads/side".to_string()];
3189        let mut options = default_options();
3190        options.update_head_ok = true;
3191        let mut credentials = NoCredentials;
3192        let mut progress = SilentProgress;
3193
3194        let error = fetch(
3195            FetchRequest {
3196                git_dir: &local,
3197                format: ObjectFormat::Sha1,
3198                config: &GitConfig::default(),
3199                remote_name: ".",
3200                source: &source,
3201                refspecs: &refspecs,
3202                options: &options,
3203                validation: None,
3204            },
3205            FetchServices {
3206                credentials: &mut credentials,
3207                progress: &mut progress,
3208                ref_hook: None,
3209            },
3210        )
3211        .expect_err("divergent update must fail");
3212        assert!(error.to_string().contains("non-fast-forward"));
3213        let refs = FileRefStore::new(&local, ObjectFormat::Sha1);
3214        assert_eq!(
3215            refs.read_ref("refs/heads/side").expect("read side"),
3216            Some(RefTarget::Direct(local_tip))
3217        );
3218
3219        options.force = true;
3220        fetch(
3221            FetchRequest {
3222                git_dir: &local,
3223                format: ObjectFormat::Sha1,
3224                config: &GitConfig::default(),
3225                remote_name: ".",
3226                source: &source,
3227                refspecs: &refspecs,
3228                options: &options,
3229                validation: None,
3230            },
3231            FetchServices {
3232                credentials: &mut credentials,
3233                progress: &mut progress,
3234                ref_hook: None,
3235            },
3236        )
3237        .expect("forced fetch should update");
3238        assert_eq!(
3239            refs.read_ref("refs/heads/side").expect("read forced side"),
3240            Some(RefTarget::Direct(remote_tip))
3241        );
3242    }
3243
3244    #[test]
3245    fn redundant_symbolic_destination_is_preserved_when_target_is_updated() {
3246        let local = temp_repo("symref-alias");
3247        let tip = commit_on(&local, "main", "tip");
3248        let refs = FileRefStore::new(&local, ObjectFormat::Sha1);
3249        let mut tx = refs.transaction();
3250        tx.update(RefUpdate {
3251            name: "refs/remotes/origin/main".into(),
3252            expected: None,
3253            new: RefTarget::Direct(tip),
3254            reflog: None,
3255        });
3256        tx.update(RefUpdate {
3257            name: "refs/remotes/origin/HEAD".into(),
3258            expected: None,
3259            new: RefTarget::Symbolic("refs/remotes/origin/main".into()),
3260            reflog: None,
3261        });
3262        tx.commit().expect("remote refs");
3263        let mut updates = vec![
3264            FetchRefUpdate {
3265                src: "refs/remotes/origin/HEAD".into(),
3266                dst: Some("refs/remotes/origin/HEAD".into()),
3267                oid: tip,
3268                not_for_merge: false,
3269                force: false,
3270            },
3271            FetchRefUpdate {
3272                src: "refs/remotes/origin/main".into(),
3273                dst: Some("refs/remotes/origin/main".into()),
3274                oid: tip,
3275                not_for_merge: false,
3276                force: false,
3277            },
3278        ];
3279
3280        drop_redundant_symref_alias_updates(&refs, &mut updates).expect("dedupe alias");
3281        assert_eq!(updates.len(), 1);
3282        assert_eq!(updates[0].dst.as_deref(), Some("refs/remotes/origin/main"));
3283        assert_eq!(
3284            refs.read_ref("refs/remotes/origin/HEAD")
3285                .expect("read symref"),
3286            Some(RefTarget::Symbolic("refs/remotes/origin/main".into()))
3287        );
3288    }
3289
3290    #[test]
3291    fn local_fetch_installs_pack_updates_ref_and_fetch_head() {
3292        let remote = temp_repo("remote");
3293        let local = temp_repo("local");
3294        let tip = commit_on(&remote, "main", "remote tip");
3295        let source = FetchSource::Local {
3296            git_dir: remote.clone(),
3297            common_git_dir: remote.clone(),
3298        };
3299        let refspecs = vec!["refs/heads/main:refs/remotes/origin/main".to_string()];
3300        let options = default_options();
3301        let config = GitConfig::parse(b"[fetch]\n\tfsckObjects = true\n").expect("config");
3302        let policy = sley_fsck::FsckPolicy::from_config(
3303            &config,
3304            sley_fsck::FsckConfigKind::Fetch,
3305            ObjectFormat::Sha1,
3306            &local,
3307            false,
3308        )
3309        .expect("fetch policy");
3310        let mut credentials = NoCredentials;
3311        let mut progress = SilentProgress;
3312
3313        let outcome = fetch(
3314            FetchRequest {
3315                git_dir: &local,
3316                format: ObjectFormat::Sha1,
3317                config: &config,
3318                remote_name: "origin",
3319                source: &source,
3320                refspecs: &refspecs,
3321                options: &options,
3322                validation: Some(&policy),
3323            },
3324            FetchServices {
3325                credentials: &mut credentials,
3326                progress: &mut progress,
3327                ref_hook: None,
3328            },
3329        )
3330        .expect("fetch should succeed");
3331
3332        assert_eq!(outcome.ref_updates.len(), 1);
3333        assert!(outcome.wrote_fetch_head);
3334        let local_db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
3335        assert!(local_db.contains(&tip).expect("contains should read"));
3336        let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
3337        assert_eq!(
3338            local_refs
3339                .read_ref("refs/remotes/origin/main")
3340                .expect("ref should read"),
3341            Some(RefTarget::Direct(tip))
3342        );
3343        let fetch_head = fs::read_to_string(local.join("FETCH_HEAD")).expect("FETCH_HEAD exists");
3344        assert!(fetch_head.contains("origin"));
3345    }
3346
3347    /// An [`HttpClient`] test double that records how many times it was dialed and
3348    /// serves a fixed smart-HTTP `info/refs` advertisement. Standing in for a
3349    /// host's SSRF-guarding client, it proves the fetch/clone HTTP path routes the
3350    /// dial through the injected client rather than constructing a fresh ureq one.
3351    struct RecordingHttpClient {
3352        advertisement: Vec<u8>,
3353        content_type: String,
3354        get_calls: std::sync::atomic::AtomicUsize,
3355        post_calls: std::sync::atomic::AtomicUsize,
3356    }
3357
3358    impl HttpClient for RecordingHttpClient {
3359        fn get(
3360            &self,
3361            _url: &str,
3362            _headers: &[(&str, &str)],
3363        ) -> Result<sley_transport::HttpResponse> {
3364            self.get_calls
3365                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3366            Ok(sley_transport::HttpResponse {
3367                status: 200,
3368                content_type: Some(self.content_type.clone()),
3369                body: Box::new(std::io::Cursor::new(self.advertisement.clone())),
3370            })
3371        }
3372
3373        fn post(
3374            &self,
3375            _url: &str,
3376            _content_type: &str,
3377            _headers: &[(&str, &str)],
3378            _body: &[u8],
3379        ) -> Result<sley_transport::HttpResponse> {
3380            self.post_calls
3381                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3382            Err(GitError::Command(
3383                "recording client received an unexpected POST".into(),
3384            ))
3385        }
3386    }
3387
3388    #[test]
3389    fn fetch_with_http_client_dials_injected_client() {
3390        use sley_protocol::{
3391            GitService, ProtocolVersion, RefAdvertisement, RefAdvertisementSet,
3392            smart_http_advertisement_content_type,
3393        };
3394        use sley_transport::{
3395            ServiceAnnouncement, ServiceDiscoveryPayload, ServiceDiscoveryResponse,
3396            parse_remote_url, write_service_discovery_response,
3397        };
3398
3399        let local = temp_repo("http-inject");
3400        // Commit the advertised tip locally so the negotiated `want` is already
3401        // present: the fetch then completes after the ref-advertisement GET
3402        // without any pack POST, keeping the test to a single dial.
3403        let tip = commit_on(&local, "main", "already-present tip");
3404
3405        // A protocol-v0 upload-pack advertisement announcing refs/heads/main = tip.
3406        let advertisement = {
3407            let response = ServiceDiscoveryResponse {
3408                announcement: ServiceAnnouncement {
3409                    service: GitService::UploadPack,
3410                },
3411                payload: ServiceDiscoveryPayload::AdvertisedRefs(RefAdvertisementSet {
3412                    protocol: ProtocolVersion::V0,
3413                    refs: vec![RefAdvertisement {
3414                        oid: tip.clone(),
3415                        name: "refs/heads/main".into(),
3416                        capabilities: Vec::new(),
3417                    }],
3418                    shallow: Vec::new(),
3419                }),
3420            };
3421            let mut body = Vec::new();
3422            write_service_discovery_response(&mut body, &response)
3423                .expect("advertisement should encode");
3424            body
3425        };
3426
3427        let client = RecordingHttpClient {
3428            advertisement,
3429            content_type: smart_http_advertisement_content_type(GitService::UploadPack)
3430                .expect("content type"),
3431            get_calls: std::sync::atomic::AtomicUsize::new(0),
3432            post_calls: std::sync::atomic::AtomicUsize::new(0),
3433        };
3434
3435        let source =
3436            FetchSource::Http(parse_remote_url("http://example.invalid/repo.git").expect("url"));
3437        let options = default_options();
3438        let mut credentials = NoCredentials;
3439        let mut progress = SilentProgress;
3440
3441        let outcome = fetch_with_http_client(
3442            FetchRequest {
3443                git_dir: &local,
3444                format: ObjectFormat::Sha1,
3445                config: &GitConfig::default(),
3446                remote_name: "origin",
3447                source: &source,
3448                refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
3449                options: &options,
3450                validation: None,
3451            },
3452            FetchServices {
3453                credentials: &mut credentials,
3454                progress: &mut progress,
3455                ref_hook: None,
3456            },
3457            Some(&client),
3458        )
3459        .expect("fetch via injected client should succeed");
3460
3461        // The injected client owned the dial — a fresh ureq client would instead
3462        // have tried to resolve example.invalid and never touched this recorder.
3463        assert_eq!(
3464            client.get_calls.load(std::sync::atomic::Ordering::Relaxed),
3465            1,
3466            "sley must dial through the injected client"
3467        );
3468        assert_eq!(
3469            client.post_calls.load(std::sync::atomic::Ordering::Relaxed),
3470            0,
3471            "no pack POST expected when the want is already present"
3472        );
3473        // The advertisement still drove a real ref-map update.
3474        assert_eq!(outcome.ref_updates.len(), 1);
3475        let refs = FileRefStore::new(&local, ObjectFormat::Sha1);
3476        assert_eq!(
3477            refs.read_ref("refs/remotes/origin/main")
3478                .expect("ref should read"),
3479            Some(RefTarget::Direct(tip))
3480        );
3481    }
3482
3483    #[test]
3484    fn fetch_fsck_rejects_before_ref_or_fetch_head_mutation() {
3485        let remote = temp_repo("remote-fsck-reject");
3486        let local = temp_repo("local-fsck-reject");
3487        let format = ObjectFormat::Sha1;
3488        let remote_db = FileObjectDatabase::from_git_dir(&remote, format);
3489        let tree = remote_db
3490            .write_object(EncodedObject::new(
3491                ObjectType::Tree,
3492                Tree { entries: vec![] }.write(),
3493            ))
3494            .expect("tree");
3495        let malformed = format!(
3496            "tree {tree}\nauthor Bugs Bunny 1 +0000\ncommitter Bugs <bugs@example.invalid> 1 +0000\n\nbad\n"
3497        );
3498        let tip = remote_db
3499            .write_object(EncodedObject::new(
3500                ObjectType::Commit,
3501                malformed.into_bytes(),
3502            ))
3503            .expect("malformed commit");
3504        let remote_refs = FileRefStore::new(&remote, format);
3505        let mut tx = remote_refs.transaction();
3506        tx.update(RefUpdate {
3507            name: "refs/heads/main".into(),
3508            expected: None,
3509            new: RefTarget::Direct(tip),
3510            reflog: None,
3511        });
3512        tx.commit().expect("remote ref");
3513        let config = GitConfig::parse(b"[fetch]\n\tfsckObjects = true\n").expect("config");
3514        let policy = sley_fsck::FsckPolicy::from_config(
3515            &config,
3516            sley_fsck::FsckConfigKind::Fetch,
3517            format,
3518            Path::new("."),
3519            false,
3520        )
3521        .expect("policy");
3522        let source = FetchSource::Local {
3523            git_dir: remote.clone(),
3524            common_git_dir: remote,
3525        };
3526        let options = default_options();
3527        let mut credentials = NoCredentials;
3528        let mut progress = SilentProgress;
3529        let result = fetch(
3530            FetchRequest {
3531                git_dir: &local,
3532                format,
3533                config: &config,
3534                remote_name: "origin",
3535                source: &source,
3536                refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
3537                options: &options,
3538                validation: Some(&policy),
3539            },
3540            FetchServices {
3541                credentials: &mut credentials,
3542                progress: &mut progress,
3543                ref_hook: None,
3544            },
3545        );
3546        assert!(
3547            result.is_err(),
3548            "malformed incoming commit must be rejected"
3549        );
3550        assert_eq!(
3551            FileRefStore::new(&local, format)
3552                .read_ref("refs/remotes/origin/main")
3553                .expect("local ref read"),
3554            None
3555        );
3556        assert!(!local.join("FETCH_HEAD").exists());
3557        assert!(
3558            !FileObjectDatabase::from_git_dir(&local, format)
3559                .contains(&tip)
3560                .expect("rejected object lookup")
3561        );
3562    }
3563
3564    #[test]
3565    fn shallow_local_fetch_writes_depth_boundary_metadata() {
3566        let remote = temp_repo("remote-shallow");
3567        let local = temp_repo("local-shallow");
3568        let tip = commit_on(&remote, "main", "tip");
3569        let source = FetchSource::Local {
3570            git_dir: remote.clone(),
3571            common_git_dir: remote.clone(),
3572        };
3573        let mut options = default_options();
3574        options.depth = Some(1);
3575        let mut credentials = NoCredentials;
3576        let mut progress = SilentProgress;
3577
3578        fetch(
3579            FetchRequest {
3580                git_dir: &local,
3581                format: ObjectFormat::Sha1,
3582                config: &GitConfig::default(),
3583                remote_name: "origin",
3584                source: &source,
3585                refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
3586                options: &options,
3587                validation: None,
3588            },
3589            FetchServices {
3590                credentials: &mut credentials,
3591                progress: &mut progress,
3592                ref_hook: None,
3593            },
3594        )
3595        .expect("shallow fetch should succeed");
3596
3597        assert_eq!(
3598            crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3599                .expect("shallow file should read"),
3600            vec![tip]
3601        );
3602    }
3603
3604    #[test]
3605    fn shallow_validation_uses_pending_boundary_inside_quarantine() {
3606        let remote = temp_repo("remote-shallow-validation");
3607        let local = temp_repo("local-shallow-validation");
3608        let parent = commit_on(&remote, "main", "parent");
3609        let tip = commit_child_on(&remote, "main", parent, "tip");
3610        let source = FetchSource::Local {
3611            git_dir: remote.clone(),
3612            common_git_dir: remote,
3613        };
3614        let config = GitConfig::default();
3615        let policy = sley_fsck::FsckPolicy::from_config(
3616            &config,
3617            sley_fsck::FsckConfigKind::Fetch,
3618            ObjectFormat::Sha1,
3619            Path::new("."),
3620            false,
3621        )
3622        .expect("fetch validation policy");
3623        let mut options = default_options();
3624        options.depth = Some(1);
3625        let mut credentials = NoCredentials;
3626        let mut progress = SilentProgress;
3627
3628        fetch(
3629            FetchRequest {
3630                git_dir: &local,
3631                format: ObjectFormat::Sha1,
3632                config: &config,
3633                remote_name: "origin",
3634                source: &source,
3635                refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
3636                options: &options,
3637                validation: Some(&policy),
3638            },
3639            FetchServices {
3640                credentials: &mut credentials,
3641                progress: &mut progress,
3642                ref_hook: None,
3643            },
3644        )
3645        .expect("pending shallow boundary should make quarantine connected");
3646
3647        assert_eq!(
3648            crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3649                .expect("shallow file should read"),
3650            vec![tip]
3651        );
3652        assert!(
3653            !FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1)
3654                .contains(&parent)
3655                .expect("parent lookup should succeed"),
3656            "depth-one transfer should remain genuinely shallow"
3657        );
3658    }
3659
3660    #[test]
3661    fn invalid_unshallow_is_rejected_before_destination_shallow_mutation() {
3662        let remote = temp_repo("remote-invalid-unshallow");
3663        let local = temp_repo("local-invalid-unshallow");
3664        let parent = commit_on(&remote, "main", "parent");
3665        let tip = commit_child_on(&remote, "main", parent, "tip");
3666        let source = FetchSource::Local {
3667            git_dir: remote.clone(),
3668            common_git_dir: remote,
3669        };
3670        let mut shallow_options = default_options();
3671        shallow_options.depth = Some(1);
3672        let mut credentials = NoCredentials;
3673        let mut progress = SilentProgress;
3674
3675        fetch(
3676            FetchRequest {
3677                git_dir: &local,
3678                format: ObjectFormat::Sha1,
3679                config: &GitConfig::default(),
3680                remote_name: "origin",
3681                source: &source,
3682                refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
3683                options: &shallow_options,
3684                validation: None,
3685            },
3686            FetchServices {
3687                credentials: &mut credentials,
3688                progress: &mut progress,
3689                ref_hook: None,
3690            },
3691        )
3692        .expect("create depth-one destination");
3693        assert_eq!(
3694            crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3695                .expect("read initial shallow boundary"),
3696            vec![tip]
3697        );
3698        assert!(
3699            !FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1)
3700                .contains(&parent)
3701                .expect("parent lookup"),
3702            "the omitted parent makes unshallowing the tip invalid"
3703        );
3704
3705        let store = FileRefStore::new(&local, ObjectFormat::Sha1);
3706        let options = default_options();
3707        let mut quarantine = Some(
3708            sley_odb::IncomingPackQuarantine::new(&local, ObjectFormat::Sha1)
3709                .expect("empty incoming quarantine"),
3710        );
3711        let mut updates = vec![FetchRefUpdate {
3712            src: "refs/heads/main".into(),
3713            dst: Some("refs/remotes/origin/main".into()),
3714            oid: tip,
3715            not_for_merge: false,
3716            force: false,
3717        }];
3718        let mut outcome = FetchOutcome::default();
3719        let shallow_info = [sley_protocol::ProtocolV2FetchShallowInfo::Unshallow(tip)];
3720
3721        let result = finalize_fetch(
3722            FetchFinalize {
3723                git_dir: &local,
3724                format: ObjectFormat::Sha1,
3725                store: &store,
3726                options: &options,
3727                fetch_head_source: "origin",
3728                default_head_fetch: false,
3729                log_all_ref_updates: true,
3730                ref_hook: None,
3731                opportunistic_dsts: &HashSet::new(),
3732                validation: None,
3733                quarantine: &mut quarantine,
3734                shallow_info: &shallow_info,
3735            },
3736            &mut updates,
3737            &mut outcome,
3738        );
3739
3740        assert!(result.is_err(), "missing parent must reject the unshallow");
3741        assert_eq!(
3742            crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3743                .expect("destination shallow boundary survives rejection"),
3744            vec![tip]
3745        );
3746    }
3747
3748    #[test]
3749    fn relative_deepen_is_noop_for_complete_repository() {
3750        let remote = temp_repo("remote-complete-deepen");
3751        let local = temp_repo("local-complete-deepen");
3752        let parent = commit_on(&remote, "main", "parent");
3753        let tip = commit_child_on(&remote, "main", parent, "tip");
3754        let source = FetchSource::Local {
3755            git_dir: remote.clone(),
3756            common_git_dir: remote,
3757        };
3758        let refspecs = ["refs/heads/main:refs/remotes/origin/main".to_string()];
3759        let mut credentials = NoCredentials;
3760        let mut progress = SilentProgress;
3761
3762        fetch(
3763            FetchRequest {
3764                git_dir: &local,
3765                format: ObjectFormat::Sha1,
3766                config: &GitConfig::default(),
3767                remote_name: "origin",
3768                source: &source,
3769                refspecs: &refspecs,
3770                options: &default_options(),
3771                validation: None,
3772            },
3773            FetchServices {
3774                credentials: &mut credentials,
3775                progress: &mut progress,
3776                ref_hook: None,
3777            },
3778        )
3779        .expect("full fetch should succeed");
3780
3781        let mut deepen = default_options();
3782        deepen.depth = Some(1);
3783        deepen.deepen_relative = true;
3784        fetch(
3785            FetchRequest {
3786                git_dir: &local,
3787                format: ObjectFormat::Sha1,
3788                config: &GitConfig::default(),
3789                remote_name: "origin",
3790                source: &source,
3791                refspecs: &refspecs,
3792                options: &deepen,
3793                validation: None,
3794            },
3795            FetchServices {
3796                credentials: &mut credentials,
3797                progress: &mut progress,
3798                ref_hook: None,
3799            },
3800        )
3801        .expect("relative deepen from a complete repository should be a full no-op");
3802
3803        assert!(
3804            crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3805                .expect("shallow file should read")
3806                .is_empty()
3807        );
3808        let db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
3809        assert!(db.contains(&parent).expect("parent lookup"));
3810        assert!(db.contains(&tip).expect("tip lookup"));
3811    }
3812
3813    fn pack_file_count(git_dir: &Path) -> usize {
3814        fs::read_dir(git_dir.join("objects/pack"))
3815            .expect("pack directory should read")
3816            .filter_map(|entry| entry.ok())
3817            .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "pack"))
3818            .count()
3819    }
3820
3821    #[test]
3822    fn same_depth_shallow_local_fetch_does_not_install_pack() {
3823        let remote = temp_repo("remote-shallow-noop");
3824        let local = temp_repo("local-shallow-noop");
3825        let tip = commit_on(&remote, "main", "tip");
3826        let source = FetchSource::Local {
3827            git_dir: remote.clone(),
3828            common_git_dir: remote.clone(),
3829        };
3830        let mut options = default_options();
3831        options.depth = Some(1);
3832        let refspecs = ["refs/heads/main:refs/remotes/origin/main".to_string()];
3833        let mut credentials = NoCredentials;
3834        let mut progress = SilentProgress;
3835
3836        fetch(
3837            FetchRequest {
3838                git_dir: &local,
3839                format: ObjectFormat::Sha1,
3840                config: &GitConfig::default(),
3841                remote_name: "origin",
3842                source: &source,
3843                refspecs: &refspecs,
3844                options: &options,
3845                validation: None,
3846            },
3847            FetchServices {
3848                credentials: &mut credentials,
3849                progress: &mut progress,
3850                ref_hook: None,
3851            },
3852        )
3853        .expect("initial shallow fetch should succeed");
3854        let pack_count = pack_file_count(&local);
3855        let shallow = crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3856            .expect("shallow file should read");
3857
3858        fetch(
3859            FetchRequest {
3860                git_dir: &local,
3861                format: ObjectFormat::Sha1,
3862                config: &GitConfig::default(),
3863                remote_name: "origin",
3864                source: &source,
3865                refspecs: &refspecs,
3866                options: &options,
3867                validation: None,
3868            },
3869            FetchServices {
3870                credentials: &mut credentials,
3871                progress: &mut progress,
3872                ref_hook: None,
3873            },
3874        )
3875        .expect("same-depth shallow fetch should succeed");
3876
3877        assert_eq!(pack_file_count(&local), pack_count);
3878        assert_eq!(
3879            crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
3880                .expect("shallow file should read"),
3881            shallow
3882        );
3883        assert_eq!(shallow, vec![tip]);
3884    }
3885
3886    #[test]
3887    fn fetch_head_source_description_redacts_embedded_credentials() {
3888        let config = GitConfig {
3889            sections: vec![ConfigSection::new(
3890                "remote",
3891                Some("origin".into()),
3892                vec![ConfigEntry::new(
3893                    "url",
3894                    Some("https://user:pass@host/repo.git".into()),
3895                )],
3896            )],
3897            ..GitConfig::default()
3898        };
3899        assert_eq!(
3900            fetch_head_source_description(&config, "origin"),
3901            "https://<redacted>@host/repo"
3902        );
3903    }
3904
3905    #[test]
3906    fn failed_local_fetch_does_not_partially_mutate_refs_or_fetch_head() {
3907        let remote = temp_repo("remote-missing");
3908        let local = temp_repo("local-missing");
3909        let old = commit_on(&local, "main", "old local");
3910        let bogus =
3911            ObjectId::from_hex(ObjectFormat::Sha1, &"11".repeat(20)).expect("valid bogus oid");
3912        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3913        let mut tx = remote_refs.transaction();
3914        tx.update(RefUpdate {
3915            name: "refs/heads/main".into(),
3916            expected: None,
3917            new: RefTarget::Direct(bogus),
3918            reflog: None,
3919        });
3920        tx.update(RefUpdate {
3921            name: "HEAD".into(),
3922            expected: None,
3923            new: RefTarget::Symbolic("refs/heads/main".into()),
3924            reflog: None,
3925        });
3926        tx.commit().expect("remote bogus ref should write");
3927        let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
3928        let mut tx = local_refs.transaction();
3929        tx.update(RefUpdate {
3930            name: "refs/remotes/origin/main".into(),
3931            expected: None,
3932            new: RefTarget::Direct(old),
3933            reflog: None,
3934        });
3935        tx.commit().expect("local tracking ref should write");
3936        let source = FetchSource::Local {
3937            git_dir: remote.clone(),
3938            common_git_dir: remote.clone(),
3939        };
3940        let options = default_options();
3941        let mut credentials = NoCredentials;
3942        let mut progress = SilentProgress;
3943
3944        let err = fetch(
3945            FetchRequest {
3946                git_dir: &local,
3947                format: ObjectFormat::Sha1,
3948                config: &GitConfig::default(),
3949                remote_name: "origin",
3950                source: &source,
3951                refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
3952                options: &options,
3953                validation: None,
3954            },
3955            FetchServices {
3956                credentials: &mut credentials,
3957                progress: &mut progress,
3958                ref_hook: None,
3959            },
3960        )
3961        .expect_err("fetch should fail before finalizing refs");
3962
3963        assert!(err.to_string().contains("missing object"));
3964        assert_eq!(
3965            local_refs
3966                .read_ref("refs/remotes/origin/main")
3967                .expect("ref should read"),
3968            Some(RefTarget::Direct(old))
3969        );
3970        assert!(!local.join("FETCH_HEAD").exists());
3971    }
3972
3973    fn config_from_ini(ini: &str) -> GitConfig {
3974        GitConfig::parse(ini.as_bytes()).expect("config should parse")
3975    }
3976
3977    #[test]
3978    fn fetch_max_input_size_unset_means_unlimited() {
3979        assert_eq!(fetch_max_input_size(&GitConfig::default()), None);
3980    }
3981
3982    #[test]
3983    fn fetch_max_input_size_honors_fetch_section() {
3984        let cfg = config_from_ini("[fetch]\n\tmaxInputSize = 64\n");
3985        assert_eq!(fetch_max_input_size(&cfg), Some(64));
3986    }
3987
3988    #[test]
3989    fn fetch_max_input_size_falls_back_to_transfer_max_size() {
3990        let cfg = config_from_ini("[transfer]\n\tmaxSize = 1k\n");
3991        assert_eq!(fetch_max_input_size(&cfg), Some(1024));
3992    }
3993
3994    #[test]
3995    fn fetch_max_input_size_prefers_fetch_over_transfer() {
3996        let cfg = config_from_ini("[fetch]\n\tmaxInputSize = 64\n[transfer]\n\tmaxSize = 1k\n");
3997        assert_eq!(fetch_max_input_size(&cfg), Some(64));
3998    }
3999
4000    #[test]
4001    fn fetch_max_input_size_non_positive_means_unlimited() {
4002        let cfg = config_from_ini("[fetch]\n\tmaxInputSize = 0\n");
4003        assert_eq!(fetch_max_input_size(&cfg), None);
4004    }
4005
4006    #[test]
4007    fn local_fetch_unpack_limit_matches_git_defaults_and_clone_policy() {
4008        let cfg = GitConfig::default();
4009        assert_eq!(local_fetch_unpack_limit(&cfg, false, false), Some(100));
4010        assert_eq!(local_fetch_unpack_limit(&cfg, true, false), None);
4011        assert_eq!(local_fetch_unpack_limit(&cfg, false, true), None);
4012    }
4013
4014    #[test]
4015    fn local_fetch_unpack_limit_prefers_fetch_over_transfer() {
4016        let cfg = config_from_ini("[fetch]\n\tunpackLimit = 17\n[transfer]\n\tunpackLimit = 23\n");
4017        assert_eq!(local_fetch_unpack_limit(&cfg, false, false), Some(17));
4018    }
4019
4020    #[test]
4021    fn local_fetch_unpack_limit_falls_back_to_transfer_and_zero_disables_unpacking() {
4022        let transfer = config_from_ini("[transfer]\n\tunpackLimit = 23\n");
4023        assert_eq!(local_fetch_unpack_limit(&transfer, false, false), Some(23));
4024
4025        let disabled = config_from_ini("[fetch]\n\tunpackLimit = 0\n");
4026        assert_eq!(local_fetch_unpack_limit(&disabled, false, false), None);
4027    }
4028}