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::{remote_config_values, remote_exists, rewrite_url_with_config};
27use sley_core::{GitError, ObjectFormat, ObjectId, Result, redact_url_for_display};
28use sley_odb::{
29    FileObjectDatabase, ObjectReader, collect_reachable_object_ids,
30    collect_reachable_object_ids_excluding,
31};
32#[cfg(feature = "http")]
33use sley_protocol::ProtocolVersion;
34use sley_protocol::{
35    FetchHeadRecord, FetchRefUpdate, RefAdvertisement, RefSpec, encode_fetch_head,
36    fetch_ref_updates_to_fetch_head, parse_refspec, plan_fetch_ref_updates, refname_matches,
37    refspec_map_source,
38};
39use sley_refs::{FileRefStore, Ref, RefTarget, RefUpdate, ReflogEntry};
40use sley_transport::{RemoteTransport, RemoteUrl};
41#[cfg(feature = "http")]
42use sley_transport::{HttpClient, UreqHttpClient};
43
44use crate::{CredentialProvider, ProgressSink};
45
46/// How a fetch obtains refs and objects from the remote.
47///
48/// The caller resolves the remote (URL rewriting, repository discovery — all
49/// process-state dependent) and hands `fetch` a concrete transport.
50pub enum FetchSource {
51    /// A smart-HTTP(S) remote at the given already-resolved URL.
52    Http(RemoteUrl),
53    /// An SSH remote at the given already-resolved URL. Fetched by spawning `ssh`
54    /// (the credential seam is unused — the `ssh` program owns authentication).
55    Ssh(RemoteUrl),
56    /// A native anonymous `git://` remote at the given already-resolved URL.
57    Git {
58        remote: RemoteUrl,
59        protocol_v2: bool,
60    },
61    /// A local repository served in-process from `git_dir`.
62    Local {
63        /// The remote repository's `$GIT_DIR`.
64        git_dir: PathBuf,
65        /// The remote repository's common `$GIT_DIR` (object format source).
66        common_git_dir: PathBuf,
67    },
68}
69
70/// Controls for a [`fetch`] run, mirroring the `git fetch` flags the CLI parses.
71#[derive(Debug, Clone)]
72pub struct FetchOptions {
73    /// Suppress prune notices (deletions still happen; only the [`ProgressSink`]
74    /// output is silenced — the caller wires that).
75    pub quiet: bool,
76    /// Auto-follow annotated tags pointing at fetched commits.
77    pub auto_follow_tags: bool,
78    /// Fetch every tag (`--tags`), independent of reachability.
79    pub fetch_all_tags: bool,
80    /// Prune remote-tracking refs that no longer exist on the remote.
81    pub prune: bool,
82    /// Prune local tags absent from the remote when pruning is enabled.
83    pub prune_tags: bool,
84    /// Plan and report the fetch without installing objects or updating refs.
85    pub dry_run: bool,
86    /// Force ref updates (`git fetch --force`), equivalent to applying `+` to
87    /// each effective refspec.
88    pub force: bool,
89    /// Append to `FETCH_HEAD` instead of truncating it.
90    pub append: bool,
91    /// Write `FETCH_HEAD` (the CLI's `--write-fetch-head`).
92    pub write_fetch_head: bool,
93    /// Whether the tag option (`--tags`/`--no-tags`) was set explicitly, so the
94    /// configured `remote.<name>.tagopt` must not override it.
95    pub tag_option_explicit: bool,
96    /// Whether the prune option (`--prune`/`--no-prune`) was set explicitly, so
97    /// the configured `remote.<name>.prune`/`fetch.prune` must not override it.
98    pub prune_option_explicit: bool,
99    /// Whether the prune-tags option (`--prune-tags`/`--no-prune-tags`) was set
100    /// explicitly, so configured prune tag options must not override it.
101    pub prune_tags_option_explicit: bool,
102    /// Explicit `--refmap` mappings for command-line refspec tracking updates.
103    /// `None` means use `remote.<name>.fetch`; `Some([])` disables the
104    /// opportunistic tracking update.
105    pub refmap: Option<Vec<String>>,
106    /// Shallow fetch depth (`--depth N`): truncate history to `N` commits per tip.
107    /// `None` is a full fetch. Honored by the HTTP and SSH transports and by the
108    /// in-process local (`file://`/path) server, which computes the deepen
109    /// boundary itself (see [`crate::local::compute_local_deepen`]).
110    pub depth: Option<u32>,
111    /// When fetching configured remote refspecs, mark updates whose `src`
112    /// matches one of these (possibly-abbreviated) `branch.<name>.merge` values
113    /// as eligible for merge in `FETCH_HEAD`. More than one entry is an octopus
114    /// merge config. Empty falls back to git's default (first ref of the first
115    /// non-pattern configured refspec). Used by `fetch` (current-branch merge
116    /// config) and `pull`.
117    pub merge_srcs: Vec<String>,
118    /// Partial-clone object filter (`--filter=blob:none`): omit filtered
119    /// objects from the transferred pack. Local-only today: HTTP and SSH do not
120    /// send `filter` requests yet, so callers that require network filtering
121    /// must gate that before calling [`fetch`]. Directly-wanted tips are always
122    /// packed on the local path, mirroring upstream's filter traversal.
123    pub filter: Option<sley_odb::PackObjectFilter>,
124    /// `--refetch`: ignore local haves so existing reachable commits can be
125    /// repacked under a newly requested partial-clone filter.
126    pub refetch: bool,
127    /// This fetch is a clone (`fetch_pack_args.cloning`): shallow points sent
128    /// by a shallow server are accepted into `$GIT_DIR/shallow` unconditionally.
129    pub cloning: bool,
130    /// Whether an in-process local promisor install should append the wanted ref
131    /// names to the `.promisor` sidecar. No-checkout partial clone keeps these
132    /// lines; checkout hydration leaves the final sidecar empty like upstream.
133    pub record_promisor_refs: bool,
134    /// `--update-shallow`: accept new shallow points from a shallow server
135    /// (otherwise refs whose history needs them are rejected).
136    pub update_shallow: bool,
137    /// `--reject-shallow` during clone: refuse a shallow source repository.
138    pub reject_shallow: bool,
139    /// `--deepen=N`: `depth` is relative to the client's current boundary.
140    /// Local-only today; HTTP and SSH treat `depth` as an absolute `--depth N`.
141    pub deepen_relative: bool,
142    /// Allow updating the currently checked-out branch (`git fetch -u` /
143    /// `--update-head-ok`). Porcelain `pull` uses this internally.
144    pub update_head_ok: bool,
145    /// `--shallow-since=<date>`: deepen to commits newer than the date.
146    /// Local-only today; HTTP and SSH do not send `deepen-since` yet.
147    pub deepen_since: Option<i64>,
148    /// `--shallow-exclude=<ref>`: deepen to commits not reachable from the ref
149    /// (resolved on the remote; a non-ref is an error, like upstream).
150    /// Local-only today; HTTP and SSH do not send `deepen-not` yet.
151    pub deepen_not: Vec<String>,
152    /// Command-line SSH process options supplied by a higher-level porcelain
153    /// such as clone (`-4`/`-6`). When absent, fetch derives SSH options from
154    /// the effective repository config.
155    pub ssh_options: Option<crate::ssh::SshTransportOptions>,
156    /// `--atomic`: apply every remote-tracking ref update (and prune deletion)
157    /// in a single reference transaction so a single rejected update aborts the
158    /// whole fetch and leaves `FETCH_HEAD` empty. The default is non-atomic:
159    /// each ref is updated independently and a per-ref failure is reported but
160    /// does not block the others.
161    pub atomic: bool,
162    /// Command-line `--negotiation-tip`/`--negotiation-restrict` values. `None`
163    /// means use `remote.<name>.negotiationRestrict`; `Some` means the CLI list
164    /// overrides remote config.
165    pub negotiation_restrict: Option<Vec<String>>,
166    /// Command-line `--negotiation-include` values. `None` means use
167    /// `remote.<name>.negotiationInclude`; `Some` means the CLI list overrides
168    /// remote config.
169    pub negotiation_include: Option<Vec<String>>,
170}
171
172/// A remote-tracking ref removed by a prune pass.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct PrunedRef {
175    /// The short branch name on the remote (e.g. `topic`).
176    pub branch: String,
177    /// The full local ref name removed (e.g. `refs/remotes/origin/topic`).
178    pub refname: String,
179}
180
181/// The structured result of a [`fetch`].
182#[derive(Debug, Clone, Default)]
183pub struct FetchOutcome {
184    /// The ref updates that were planned (and applied unless `dry_run`), in the
185    /// order they were resolved. Includes auto-followed tags; entries without a
186    /// `dst` are fetch-only (e.g. a bare `HEAD` fetch) and update no local ref.
187    pub ref_updates: Vec<FetchRefUpdate>,
188    /// Remote-tracking refs pruned (empty unless `prune` and the remote is a
189    /// configured remote). Empty on `dry_run`.
190    pub pruned: Vec<PrunedRef>,
191    /// The remote's advertised `HEAD` symref target (e.g. `refs/heads/main`),
192    /// when the remote advertised one. Useful for resolving the default branch.
193    pub head_symref: Option<String>,
194    /// Whether `FETCH_HEAD` was written.
195    pub wrote_fetch_head: bool,
196}
197
198/// Fully resolved inputs for a [`fetch`] run.
199pub struct FetchRequest<'a> {
200    /// Local repository `$GIT_DIR`.
201    pub git_dir: &'a Path,
202    /// Local repository object format.
203    pub format: ObjectFormat,
204    /// Local repository config snapshot.
205    pub config: &'a GitConfig,
206    /// Remote name or source string used for config lookup and `FETCH_HEAD`.
207    pub remote_name: &'a str,
208    /// Already-resolved transport source.
209    pub source: &'a FetchSource,
210    /// Refspecs requested by the caller. Empty means configured fetch refspecs,
211    /// falling back to `HEAD`.
212    pub refspecs: &'a [String],
213    /// Fetch behavior flags.
214    pub options: &'a FetchOptions,
215}
216
217/// Mutable seams used while fetching.
218pub struct FetchServices<'a> {
219    /// Credential source for authenticated transports.
220    pub credentials: &'a mut dyn CredentialProvider,
221    /// Progress sink for prune notices.
222    pub progress: &'a mut dyn ProgressSink,
223    /// `reference-transaction` hook handler fired when applying remote-tracking
224    /// ref updates. `None` skips the hook (the historical behavior). The CLI
225    /// supplies a runner so `--atomic` fetches honor a hook that aborts the
226    /// transaction, matching git's `store_updated_refs`.
227    pub ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
228}
229
230/// Fetch from a resolved `source` into the repository at `git_dir`.
231///
232/// Performs the work the CLI's `fetch_http_repository`/`fetch_local_repository`
233/// did: applies configured tag/prune options, plans the ref-map for `refspecs`
234/// (empty means the remote's configured fetch refspecs, falling back to `HEAD`),
235/// installs the pack, writes `FETCH_HEAD`, applies remote-tracking updates, and
236/// prunes. `remote_name` is the remote/argument the caller resolved `source`
237/// from (used for `FETCH_HEAD` descriptions and to look up `remote.<name>.*`).
238///
239/// Emits prune notices through `progress` and returns the structured
240/// [`FetchOutcome`]; never prints or returns `GitError::Exit`.
241///
242/// The smart-HTTP transport constructs a default [`UreqHttpClient`]. Hosts that
243/// must enforce network policy on the dial (e.g. SSRF-guard a public mirror URL)
244/// call [`fetch_with_http_client`] instead to inject their own [`HttpClient`].
245pub fn fetch(request: FetchRequest<'_>, services: FetchServices<'_>) -> Result<FetchOutcome> {
246    #[cfg(feature = "http")]
247    {
248        fetch_impl(request, services, None)
249    }
250    #[cfg(not(feature = "http"))]
251    {
252        fetch_impl(request, services)
253    }
254}
255
256/// Like [`fetch`], but drives the smart-HTTP transport through a caller-provided
257/// [`HttpClient`] when `http_client` is `Some`.
258///
259/// `None` is exactly [`fetch`] (a default [`UreqHttpClient`]). A `Some` client
260/// owns the entire dial (DNS→connect→TLS) for every smart-HTTP request, letting a
261/// host enforce network policy such as SSRF validation on the resolved IP. Only
262/// the HTTP transport consults the injected client; SSH/git/local sources ignore
263/// it.
264#[cfg(feature = "http")]
265pub fn fetch_with_http_client(
266    request: FetchRequest<'_>,
267    services: FetchServices<'_>,
268    http_client: Option<&dyn HttpClient>,
269) -> Result<FetchOutcome> {
270    fetch_impl(request, services, http_client)
271}
272
273fn fetch_impl(
274    request: FetchRequest<'_>,
275    services: FetchServices<'_>,
276    #[cfg(feature = "http")] http_client: Option<&dyn HttpClient>,
277) -> Result<FetchOutcome> {
278    let ref_hook = services.ref_hook;
279    let mut options = request.options.clone();
280    apply_configured_remote_tag_option(request.config, request.remote_name, &mut options);
281    apply_configured_fetch_prune_option(request.config, request.remote_name, &mut options);
282    crate::protocol::check_transport_allowed(
283        scheme_for_fetch_source(request.source),
284        Some(request.config),
285        None,
286    )
287    .map_err(crate::protocol::transport_policy_git_error)?;
288    // A pack must be installed as a promisor pack when the remote is already a
289    // promisor remote OR this fetch applies an object filter: a filtered fetch
290    // omits objects, so its pack is only valid as a `.promisor` pack (git's
291    // fetch-pack writes `.promisor` whenever the request carries a filter).
292    let promisor_remote = request
293        .config
294        .get_bool("remote", Some(request.remote_name), "promisor")
295        .unwrap_or(false)
296        || request.options.filter.is_some();
297    let max_input_size = fetch_max_input_size(request.config);
298    let configured_refspecs = if request.refspecs.is_empty() {
299        remote_config_values(request.config, request.remote_name, "fetch")
300    } else {
301        Vec::new()
302    };
303    let configured_refspecs_empty = configured_refspecs.is_empty();
304    // git's `get_ref_map`: a default fetch (no command-line refspecs) of the
305    // current branch's tracking remote also fetches the branch's
306    // `branch.<x>.merge` refs (`add_merge_config`) as source-only refs recorded
307    // for-merge in FETCH_HEAD. When the remote has no configured fetch refspec
308    // either, those merge refs replace the bare-`HEAD` default fetch entirely.
309    let has_merge_config = request.refspecs.is_empty() && !options.merge_srcs.is_empty();
310    let default_head_fetch =
311        request.refspecs.is_empty() && configured_refspecs_empty && !has_merge_config;
312    let configured_remote_fetch = request.refspecs.is_empty() && !configured_refspecs_empty;
313    let fetch_head_source = fetch_head_source_description(request.config, request.remote_name);
314    let prune_refspecs =
315        prune_refspecs_for_source(&configured_refspecs, request.refspecs, options.prune_tags);
316    let mut effective_refspecs = fetch_refspecs_for_source(
317        configured_refspecs,
318        request.refspecs,
319        options.fetch_all_tags,
320    );
321    if options.prune_tags
322        && request.refspecs.is_empty()
323        && !effective_refspecs
324            .iter()
325            .any(|refspec| refspec == "refs/tags/*:refs/tags/*")
326    {
327        effective_refspecs.push("refs/tags/*:refs/tags/*".to_string());
328    }
329    if has_merge_config {
330        // Drop the synthetic bare-`HEAD` refspec the helper inserts when nothing
331        // is configured; the merge refs are fetched for-merge instead.
332        if configured_refspecs_empty && request.refspecs.is_empty() {
333            effective_refspecs.retain(|spec| spec != "HEAD");
334        }
335        // Parse the configured refspecs so coverage (pattern-aware) can be tested
336        // against their sources, mirroring `add_merge_config`'s ref-map lookup.
337        let configured_parsed = effective_refspecs
338            .iter()
339            .map(|refspec| parse_refspec(refspec))
340            .collect::<Result<Vec<_>>>()?;
341        for merge_src in &options.merge_srcs {
342            // git fetches a merge ref only when it is not already reachable
343            // through a configured fetch refspec (`add_merge_config`). A glob
344            // refspec like `refs/heads/*` already covers `refs/heads/three`.
345            let covered = configured_parsed.iter().any(|refspec| {
346                refspec
347                    .src
348                    .as_deref()
349                    .is_some_and(|src| refspec_source_covers(refspec, src, merge_src))
350            });
351            if !covered {
352                // Source-only refspec (no `:dst`): fetched and written to
353                // FETCH_HEAD but creating no local ref.
354                effective_refspecs.push(merge_src.clone());
355            }
356        }
357    }
358    let mut parsed_refspecs = effective_refspecs
359        .iter()
360        .map(|refspec| parse_refspec(refspec))
361        .collect::<Result<Vec<_>>>()?;
362    if options.force {
363        for refspec in &mut parsed_refspecs {
364            refspec.force = true;
365        }
366    }
367    if options.refmap.is_some() && request.refspecs.is_empty() {
368        return Err(GitError::Command(
369            "--refmap option is only meaningful with command-line refspec(s)".into(),
370        ));
371    }
372    let tracking_refspec_strings = if request.refspecs.is_empty() {
373        Vec::new()
374    } else {
375        options.refmap.clone().unwrap_or_else(|| {
376            configured_refspecs_for_tracking(request.config, request.remote_name)
377        })
378    };
379    let tracking_refspecs = tracking_refspec_strings
380        .iter()
381        .map(|refspec| parse_refspec(refspec))
382        .collect::<Result<Vec<_>>>()?;
383    let parsed_prune_refspecs = prune_refspecs
384        .iter()
385        .map(|refspec| parse_refspec(refspec))
386        .collect::<Result<Vec<_>>>()?;
387
388    let store = FileRefStore::new(request.git_dir, request.format);
389    let negotiation_haves = custom_negotiation_haves(
390        request.git_dir,
391        request.format,
392        request.config,
393        request.remote_name,
394        &options,
395    )?;
396    let mut outcome = FetchOutcome::default();
397
398    // Advertise refs, plan the ref-map, install the pack, then update refs/prune.
399    // The two transports differ only in how they advertise and how they pull the
400    // pack; the ref-map planning and ref bookkeeping are identical.
401    let advertisements = match request.source {
402        #[cfg(not(feature = "http"))]
403        FetchSource::Http(_) => {
404            return Err(GitError::Unsupported(
405                "HTTP transport is not enabled in this build".into(),
406            ));
407        }
408        #[cfg(feature = "http")]
409        FetchSource::Http(remote) => {
410            // Use the caller-injected client when present (host network policy /
411            // SSRF guard); otherwise construct the default ureq-backed client.
412            let default_client;
413            let client: &dyn HttpClient = match http_client {
414                Some(client) => client,
415                None => {
416                    default_client = UreqHttpClient::new();
417                    &default_client
418                }
419            };
420            let git_protocol =
421                crate::http::http_git_protocol_header_value(Some(request.config))?;
422            let discovered = crate::http::http_service_advertisements(
423                client,
424                remote,
425                request.format,
426                sley_protocol::GitService::UploadPack,
427                services.credentials,
428                Some(request.config),
429            )?;
430            let advertisements = discovered.set.refs;
431            let features = crate::http::http_upload_pack_features(
432                &advertisements,
433                discovered.handshake.as_ref(),
434            )?;
435            outcome.head_symref = head_symref_from_features(&features.symrefs);
436            let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
437                advertisements: &advertisements,
438                refspecs: &parsed_refspecs,
439                options: &options,
440                store: &store,
441                reachable: None,
442                local_db: None,
443                deepen_excluded: None,
444                format: request.format,
445                configured_remote_fetch,
446                has_merge_config,
447                tracking_refspecs: &tracking_refspecs,
448            })?;
449            let wants = updates.iter().map(|update| update.oid).collect();
450            // Shallow fetch: replay the current boundary as `shallow` lines and ask
451            // the server to deepen to `depth`, then fold the server's shallow-info
452            // back into `$GIT_DIR/shallow`. A `None` depth keeps the full-fetch path.
453            let existing_shallow =
454                shallow_boundary_for_request(request.git_dir, request.format, &options)?;
455            let deepen_not = resolve_deepen_not_refs(&advertisements, &options.deepen_not)?;
456            let pack_request = crate::http::HttpFetchPackRequest {
457                client,
458                git_dir: request.git_dir,
459                format: request.format,
460                remote,
461                wants,
462                haves: negotiation_haves.clone(),
463                shallow: existing_shallow,
464                deepen: options.depth,
465                promisor: promisor_remote,
466                max_input_size,
467                filter: options.filter.clone(),
468                deepen_since: options.deepen_since,
469                deepen_not,
470                deepen_relative: options.deepen_relative,
471                git_protocol: git_protocol.as_deref(),
472                omit_haves: false,
473            };
474            let shallow_info = if discovered.set.protocol == ProtocolVersion::V2 {
475                let handshake = discovered.handshake.as_ref().ok_or_else(|| {
476                    GitError::InvalidFormat(
477                        "protocol v2 HTTP fetch requires a v2 handshake from service discovery"
478                            .into(),
479                    )
480                })?;
481                crate::http::install_fetch_pack_via_http_protocol_v2_fetch(
482                    pack_request,
483                    handshake,
484                    services.credentials,
485                    services.progress,
486                )?
487            } else {
488                crate::http::install_fetch_pack_via_http_upload_pack(
489                    pack_request,
490                    services.credentials,
491                    services.progress,
492                )?
493            };
494            reject_shallow_clone_fetch(&options, &shallow_info)?;
495            if !options.dry_run {
496                crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
497            }
498            finalize_fetch(
499                FetchFinalize {
500                    git_dir: request.git_dir,
501                    format: request.format,
502                    store: &store,
503                    options: &options,
504                    fetch_head_source: &fetch_head_source,
505                    default_head_fetch,
506                    log_all_ref_updates: fetch_log_all_ref_updates(request.config),
507                    ref_hook,
508                    opportunistic_dsts: &opportunistic_dsts,
509                },
510                &mut updates,
511                &mut outcome,
512            )?;
513            advertisements
514        }
515        FetchSource::Ssh(remote) => {
516            crate::ssh::validate_ssh_fetch_options(
517                options.filter.as_ref(),
518                options.deepen_since,
519                &options.deepen_not,
520            )?;
521            // SSH advertises and pulls the pack by spawning `ssh` (no credential
522            // seam — the `ssh` program authenticates), but the ref-map planning
523            // and ref bookkeeping are the same shared flow as HTTP.
524            let ssh_options = options
525                .ssh_options
526                .unwrap_or_else(|| crate::ssh::ssh_transport_options_from_config(request.config));
527            let (advertisements, features) =
528                crate::ssh::ssh_upload_pack_advertisements_with_options(
529                    remote,
530                    request.format,
531                    ssh_options,
532                )?;
533            outcome.head_symref = head_symref_from_features(&features.symrefs);
534            let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
535                advertisements: &advertisements,
536                refspecs: &parsed_refspecs,
537                options: &options,
538                store: &store,
539                reachable: None,
540                local_db: None,
541                deepen_excluded: None,
542                format: request.format,
543                configured_remote_fetch,
544                has_merge_config,
545                tracking_refspecs: &tracking_refspecs,
546            })?;
547            if remote.transport == RemoteTransport::Ext && options.auto_follow_tags {
548                append_missing_ext_advertised_tags(
549                    &advertisements,
550                    &parsed_refspecs,
551                    &store,
552                    &mut updates,
553                )?;
554            }
555            let wants = updates.iter().map(|update| update.oid).collect();
556            // Shallow fetch over SSH mirrors the HTTP path: replay the current
557            // boundary, deepen to `depth`, then apply the server's shallow-info.
558            let existing_shallow =
559                shallow_boundary_for_request(request.git_dir, request.format, &options)?;
560            let shallow_info = crate::ssh::install_fetch_pack_via_ssh_upload_pack(
561                crate::ssh::SshFetchPackRequest {
562                    git_dir: request.git_dir,
563                    format: request.format,
564                    remote,
565                    features: &features,
566                    wants,
567                    haves: negotiation_haves.clone(),
568                    shallow: existing_shallow,
569                    deepen: options.depth,
570                    promisor: promisor_remote,
571                    command_options: ssh_options,
572                    max_input_size,
573                },
574                services.progress,
575            )?;
576            reject_shallow_clone_fetch(&options, &shallow_info)?;
577            if !options.dry_run {
578                crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
579            }
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                },
592                &mut updates,
593                &mut outcome,
594            )?;
595            advertisements
596        }
597        FetchSource::Git {
598            remote,
599            protocol_v2,
600        } => {
601            let protocol_v2 =
602                *protocol_v2 || request.config.get("protocol", None, "version") == Some("2");
603            let discovered = crate::git::git_upload_pack_advertisements_with_protocol(
604                remote,
605                request.format,
606                protocol_v2,
607                Some(request.config),
608            )?;
609            let advertisements = discovered.refs;
610            let features = discovered.features;
611            outcome.head_symref = head_symref_from_features(&features.symrefs);
612            let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
613                advertisements: &advertisements,
614                refspecs: &parsed_refspecs,
615                options: &options,
616                store: &store,
617                reachable: None,
618                local_db: None,
619                deepen_excluded: None,
620                format: request.format,
621                configured_remote_fetch,
622                has_merge_config,
623                tracking_refspecs: &tracking_refspecs,
624            })?;
625            let wants = updates.iter().map(|update| update.oid).collect();
626            let existing_shallow =
627                shallow_boundary_for_request(request.git_dir, request.format, &options)?;
628            let shallow_info = crate::git::install_fetch_pack_via_git_upload_pack(
629                crate::git::GitFetchPackRequest {
630                    git_dir: request.git_dir,
631                    format: request.format,
632                    remote,
633                    config: Some(request.config),
634                    features: &features,
635                    wants,
636                    haves: negotiation_haves.clone(),
637                    shallow: existing_shallow,
638                    deepen: options.depth,
639                    promisor: promisor_remote,
640                    protocol_v2: discovered.protocol_v2,
641                    max_input_size,
642                },
643                services.progress,
644            )?;
645            reject_shallow_clone_fetch(&options, &shallow_info)?;
646            if !options.dry_run {
647                crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
648            }
649            finalize_fetch(
650                FetchFinalize {
651                    git_dir: request.git_dir,
652                    format: request.format,
653                    store: &store,
654                    options: &options,
655                    fetch_head_source: &fetch_head_source,
656                    default_head_fetch,
657                    log_all_ref_updates: fetch_log_all_ref_updates(request.config),
658                    ref_hook,
659                    opportunistic_dsts: &opportunistic_dsts,
660                },
661                &mut updates,
662                &mut outcome,
663            )?;
664            advertisements
665        }
666        FetchSource::Local {
667            git_dir: remote_git_dir,
668            common_git_dir: remote_common_git_dir,
669        } => {
670            let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
671            if remote_format != request.format {
672                return Err(GitError::InvalidObjectId(format!(
673                    "remote repository uses {}, local repository uses {}",
674                    remote_format.name(),
675                    request.format.name()
676                )));
677            }
678            let advertisements =
679                crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
680            // The remote's advertised HEAD symref target (e.g. `refs/heads/main`),
681            // used by the CLI to create `refs/remotes/<remote>/HEAD` on a default
682            // fetch — parity with the network transports' `head_symref`.
683            if advertisements
684                .iter()
685                .any(|advertisement| advertisement.name == "HEAD")
686                && let Some(RefTarget::Symbolic(target)) =
687                    FileRefStore::new_without_reference_backend_env(remote_git_dir, request.format)
688                        .read_ref("HEAD")?
689            {
690                outcome.head_symref = Some(target);
691            }
692            let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, request.format);
693            // Shallow fetch: the in-process upload-pack needs its deepen plan up
694            // front. The boundary walk starts from the primary planned tips
695            // (upload-pack's `want_obj`) — auto-followed tags are this path's
696            // include-tag equivalent and must not deepen the walk, and the tag
697            // auto-follow below must not see history past the boundary. The
698            // primary plan is recomputed inside `plan_and_adjust_updates`; the
699            // planner is a pure function over the same inputs, so both runs
700            // agree. A `None` depth keeps the full-fetch path.
701            // The remote's own boundary: a shallow server reports its graft
702            // points on ANY fetch (upstream `send_shallow_info` runs an
703            // implicit INFINITE_DEPTH deepen when no deepen was requested).
704            let remote_shallow =
705                crate::shallow::read_shallow(remote_common_git_dir, request.format)?;
706            let explicit_deepen = options.depth.is_some()
707                || options.deepen_since.is_some()
708                || !options.deepen_not.is_empty();
709            let implicit_deepen = !explicit_deepen && !remote_shallow.is_empty();
710            // `--shallow-exclude` values must name refs on the remote
711            // (upstream upload-pack `process_deepen_not`).
712            let mut deepen_not_oids = Vec::new();
713            for name in &options.deepen_not {
714                let resolved = advertisements.iter().find(|advertisement| {
715                    advertisement.name == *name
716                        || advertisement.name == format!("refs/tags/{name}")
717                        || advertisement.name == format!("refs/heads/{name}")
718                        || advertisement.name == format!("refs/{name}")
719                });
720                match resolved {
721                    Some(advertisement) => deepen_not_oids.push(advertisement.oid),
722                    None => {
723                        return Err(GitError::Command(format!(
724                            "git upload-pack: deepen-not is not a ref: {name}"
725                        )));
726                    }
727                }
728            }
729            let plan_deepen = |heads: &[ObjectId]| -> Result<Option<LocalDeepenPlan>> {
730                if !explicit_deepen && !implicit_deepen {
731                    return Ok(None);
732                }
733                // Replay the current boundary, like the HTTP and SSH paths.
734                let client_shallow = crate::shallow::read_shallow(request.git_dir, request.format)?;
735                if options.deepen_since.is_some() || !deepen_not_oids.is_empty() {
736                    return Ok(Some(crate::local::compute_local_deepen_by_rev_list(
737                        &remote_db,
738                        request.format,
739                        heads,
740                        client_shallow,
741                        options.deepen_since,
742                        &deepen_not_oids,
743                    )?));
744                }
745                let depth = options.depth.unwrap_or(crate::local::INFINITE_DEPTH);
746                Ok(Some(crate::local::compute_local_deepen(
747                    &remote_db,
748                    request.format,
749                    heads,
750                    client_shallow,
751                    depth,
752                    options.deepen_relative,
753                )?))
754            };
755            let primary_heads = {
756                let primary = plan_fetch_ref_updates(
757                    &advertisements,
758                    &parsed_refspecs,
759                    options.auto_follow_tags,
760                )?;
761                let mut seen = HashSet::new();
762                let mut heads = Vec::new();
763                for update in &primary {
764                    if seen.insert(update.oid) {
765                        heads.push(update.oid);
766                    }
767                }
768                heads
769            };
770            let mut deepen_plan = plan_deepen(&primary_heads)?;
771            let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
772            let (mut updates, opportunistic_dsts) = plan_and_adjust_updates(FetchPlanInput {
773                advertisements: &advertisements,
774                refspecs: &parsed_refspecs,
775                options: &options,
776                store: &store,
777                reachable: Some((&remote_db, &advertisements)),
778                local_db: Some(&local_db),
779                deepen_excluded: deepen_plan.as_ref().map(|plan| &plan.excluded),
780                format: request.format,
781                configured_remote_fetch,
782                has_merge_config,
783                tracking_refspecs: &tracking_refspecs,
784            })?;
785            // A shallow server's new boundary points are only written on a
786            // clone, an explicit deepen, or `--update-shallow`; otherwise the
787            // refs whose history would need them are rejected and dropped
788            // (upstream fetch-pack `update_shallow` + REF_STATUS_REJECT_SHALLOW).
789            if implicit_deepen && !options.cloning && !options.update_shallow {
790                let client_shallow: HashSet<ObjectId> =
791                    crate::shallow::read_shallow(request.git_dir, request.format)?
792                        .into_iter()
793                        .collect();
794                let new_points: HashSet<ObjectId> = deepen_plan
795                    .as_ref()
796                    .map(|plan| {
797                        plan.shallow_info
798                            .iter()
799                            .filter_map(|entry| match entry {
800                                sley_protocol::ProtocolV2FetchShallowInfo::Shallow(oid)
801                                    if !client_shallow.contains(oid) =>
802                                {
803                                    Some(*oid)
804                                }
805                                _ => None,
806                            })
807                            .collect()
808                    })
809                    .unwrap_or_default();
810                if !new_points.is_empty() {
811                    let mut dirty_cache: HashMap<ObjectId, bool> = HashMap::new();
812                    let mut dirty = |tip: &ObjectId| -> Result<bool> {
813                        if let Some(&cached) = dirty_cache.get(tip) {
814                            return Ok(cached);
815                        }
816                        let result =
817                            tip_reaches_boundary(&remote_db, request.format, tip, &new_points)?;
818                        dirty_cache.insert(*tip, result);
819                        Ok(result)
820                    };
821                    let mut kept = Vec::new();
822                    for update in updates {
823                        if dirty(&update.oid)? {
824                            continue;
825                        }
826                        kept.push(update);
827                    }
828                    updates = kept;
829                    // Re-plan the boundary from the surviving tips so the pack
830                    // walk and the shallow-info reflect only what is sent.
831                    let mut seen = HashSet::new();
832                    let mut heads = Vec::new();
833                    for update in &updates {
834                        if seen.insert(update.oid) {
835                            heads.push(update.oid);
836                        }
837                    }
838                    deepen_plan = if heads.is_empty() {
839                        None
840                    } else {
841                        plan_deepen(&heads)?
842                    };
843                }
844            }
845            let starts: Vec<ObjectId> = if options.refetch {
846                let mut seen = HashSet::new();
847                updates
848                    .iter()
849                    .map(|update| update.oid)
850                    .chain(primary_heads.iter().copied())
851                    .filter(|oid| seen.insert(*oid))
852                    .collect()
853            } else if deepen_plan.is_none() {
854                let mut starts = Vec::new();
855                for update in &updates {
856                    if !local_db.contains(&update.oid)? {
857                        starts.push(update.oid);
858                    }
859                }
860                starts
861            } else {
862                updates.iter().map(|update| update.oid).collect()
863            };
864            let shallow_info = if starts.is_empty() && deepen_plan.is_none() {
865                if !updates.is_empty() {
866                    sley_protocol::trace_packet_write_payload(b"0000");
867                }
868                Vec::new()
869            } else {
870                crate::local::install_fetch_pack_via_local_upload_pack(
871                    request.git_dir,
872                    remote_git_dir,
873                    request.format,
874                    starts,
875                    deepen_plan.as_ref(),
876                    promisor_remote,
877                    options.record_promisor_refs,
878                    options.filter.clone(),
879                    negotiation_haves.clone(),
880                    options.refetch,
881                    local_fetch_unpack_limit(request.git_dir, promisor_remote),
882                )?
883            };
884            if !options.dry_run {
885                crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
886            }
887            finalize_fetch(
888                FetchFinalize {
889                    git_dir: request.git_dir,
890                    format: request.format,
891                    store: &store,
892                    options: &options,
893                    fetch_head_source: &fetch_head_source,
894                    default_head_fetch,
895                    log_all_ref_updates: fetch_log_all_ref_updates(request.config),
896                    ref_hook,
897                    opportunistic_dsts: &opportunistic_dsts,
898                },
899                &mut updates,
900                &mut outcome,
901            )?;
902            advertisements
903        }
904    };
905
906    if options.prune && !parsed_prune_refspecs.is_empty() {
907        outcome.pruned = prune_refs_from_advertisements(
908            PruneRefsInput {
909                config: request.config,
910                store: &store,
911                remote: request.remote_name,
912                advertisements: &advertisements,
913                refspecs: &parsed_prune_refspecs,
914                dry_run: options.dry_run,
915                quiet: options.quiet,
916            },
917            services.progress,
918        )?;
919    }
920
921    Ok(outcome)
922}
923
924fn scheme_for_fetch_source(source: &FetchSource) -> &'static str {
925    match source {
926        FetchSource::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
927        FetchSource::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
928        FetchSource::Git { remote, .. } => crate::protocol::transport_scheme_for_remote(remote),
929        FetchSource::Local { .. } => "file",
930    }
931}
932
933fn local_fetch_unpack_limit(git_dir: &Path, promisor_remote: bool) -> Option<usize> {
934    if promisor_remote {
935        return None;
936    }
937    git_dir
938        .join("objects")
939        .join("info")
940        .join("alternates")
941        .exists()
942        .then_some(100)
943}
944
945/// Does the (graft-aware) history of `tip` on the remote touch one of the
946/// server's new shallow boundary points? Mirrors upstream
947/// `assign_shallow_commits_to_refs`'s per-ref reachability test.
948fn tip_reaches_boundary<R: sley_odb::ObjectReader>(
949    remote_db: &R,
950    format: ObjectFormat,
951    tip: &ObjectId,
952    boundary: &HashSet<ObjectId>,
953) -> Result<bool> {
954    let mut seen: HashSet<ObjectId> = HashSet::new();
955    let mut queue: Vec<ObjectId> = vec![*tip];
956    while let Some(oid) = queue.pop() {
957        if !seen.insert(oid) {
958            continue;
959        }
960        let object = remote_db.read_object(&oid)?;
961        let commit = match object.object_type {
962            sley_object::ObjectType::Commit => {
963                sley_object::Commit::parse_ref(format, &object.body)?
964            }
965            sley_object::ObjectType::Tag => {
966                let tag = sley_object::Tag::parse_ref(format, &object.body)?;
967                queue.push(tag.object);
968                continue;
969            }
970            _ => continue,
971        };
972        if boundary.contains(&oid) {
973            return Ok(true);
974        }
975        queue.extend(sley_odb::grafted_parents(remote_db, &oid, commit.parents));
976    }
977    Ok(false)
978}
979
980/// The shallow boundary to replay in a deepen request: the oids in
981/// `$GIT_DIR/shallow` when `depth` is set, otherwise empty (a full fetch sends no
982/// `shallow` lines). Reading the file only when deepening keeps the non-shallow
983/// path's wire form unchanged.
984fn shallow_boundary_for_request(
985    git_dir: &Path,
986    format: ObjectFormat,
987    options: &FetchOptions,
988) -> Result<Vec<ObjectId>> {
989    if options.depth.is_none()
990        && options.deepen_since.is_none()
991        && options.deepen_not.is_empty()
992    {
993        return Ok(Vec::new());
994    }
995    crate::shallow::read_shallow(git_dir, format)
996}
997
998fn resolve_deepen_not_refs(
999    advertisements: &[RefAdvertisement],
1000    deepen_not: &[String],
1001) -> Result<Vec<String>> {
1002    let mut resolved = Vec::with_capacity(deepen_not.len());
1003    for name in deepen_not {
1004        let found = advertisements.iter().any(|advertisement| {
1005            advertisement.name == *name
1006                || advertisement.name == format!("refs/tags/{name}")
1007                || advertisement.name == format!("refs/heads/{name}")
1008                || advertisement.name == format!("refs/{name}")
1009        });
1010        if found {
1011            resolved.push(name.clone());
1012        } else {
1013            return Err(GitError::Command(format!(
1014                "git upload-pack: deepen-not is not a ref: {name}"
1015            )));
1016        }
1017    }
1018    Ok(resolved)
1019}
1020
1021/// Plan the ref-map and apply the auto-follow-tag / not-for-merge adjustments
1022/// shared by both transports. `reachable` (local only) enables appending tags
1023/// reachable from fetched commits via the remote object database;
1024/// `deepen_excluded` (local shallow fetch only) keeps that reachability walk
1025/// from crossing the deepen boundary.
1026struct FetchPlanInput<'a> {
1027    advertisements: &'a [RefAdvertisement],
1028    refspecs: &'a [RefSpec],
1029    options: &'a FetchOptions,
1030    store: &'a FileRefStore,
1031    reachable: Option<(&'a FileObjectDatabase, &'a [RefAdvertisement])>,
1032    /// The local repository's object database, used to follow tags whose target
1033    /// is already present locally (git's `find_non_local_tags` `odb_has_object`
1034    /// check). Only the local transport supplies it; auto-follow is local-only.
1035    local_db: Option<&'a FileObjectDatabase>,
1036    deepen_excluded: Option<&'a HashSet<ObjectId>>,
1037    format: ObjectFormat,
1038    configured_remote_fetch: bool,
1039    /// Default fetch (no command-line refspecs) of the current branch's tracking
1040    /// remote with `branch.<x>.merge` configured. The merge refs drive which
1041    /// FETCH_HEAD entries are for-merge (`add_merge_config`).
1042    has_merge_config: bool,
1043    /// Opportunistic tracking mappings used only for command-line refspecs.
1044    tracking_refspecs: &'a [RefSpec],
1045}
1046
1047fn plan_and_adjust_updates(
1048    input: FetchPlanInput<'_>,
1049) -> Result<(Vec<FetchRefUpdate>, HashSet<String>)> {
1050    let FetchPlanInput {
1051        advertisements,
1052        refspecs,
1053        options,
1054        store,
1055        reachable,
1056        local_db,
1057        deepen_excluded,
1058        format,
1059        configured_remote_fetch,
1060        has_merge_config,
1061        tracking_refspecs,
1062    } = input;
1063    let visible_advertisements = advertisements_without_peeled_refs(advertisements);
1064    let planning_advertisements = if visible_advertisements.len() == advertisements.len() {
1065        advertisements
1066    } else {
1067        visible_advertisements.as_slice()
1068    };
1069    let mut updates =
1070        plan_fetch_ref_updates(planning_advertisements, refspecs, options.auto_follow_tags)?;
1071    if options.fetch_all_tags {
1072        mark_tag_refspec_updates_not_for_merge(&mut updates);
1073    } else {
1074        if options.auto_follow_tags
1075            && let Some((remote_db, advertisements)) = reachable
1076        {
1077            let visible_reachable_advertisements =
1078                advertisements_without_peeled_refs(advertisements);
1079            let reachable_advertisements =
1080                if visible_reachable_advertisements.len() == advertisements.len() {
1081                    advertisements
1082                } else {
1083                    visible_reachable_advertisements.as_slice()
1084                };
1085            append_reachable_auto_follow_tags(
1086                reachable_advertisements,
1087                remote_db,
1088                local_db,
1089                format,
1090                refspecs,
1091                &mut updates,
1092                deepen_excluded,
1093            )?;
1094        }
1095        retain_missing_auto_follow_tags(store, &mut updates)?;
1096    }
1097    if configured_remote_fetch || has_merge_config {
1098        for update in &mut updates {
1099            update.not_for_merge = true;
1100        }
1101        if !options.merge_srcs.is_empty() {
1102            // The current branch's `branch.<name>.merge` ref(s) are what we'll
1103            // merge, so they are the for-merge entries in FETCH_HEAD. Each entry
1104            // is matched with git's abbreviation rules (`branch_merge_matches`);
1105            // more than one is an octopus merge config.
1106            for update in &mut updates {
1107                if options
1108                    .merge_srcs
1109                    .iter()
1110                    .any(|src| refname_matches(src, &update.src))
1111                {
1112                    update.not_for_merge = false;
1113                }
1114            }
1115        } else if let Some(first) = refspecs.iter().find(|refspec| !refspec.negative)
1116            && !first.pattern
1117        {
1118            // No merge config: mirror git's get_ref_map default, which marks the
1119            // first matched ref of the first configured (non-pattern) fetch
1120            // refspec as for-merge. Pattern-led configs (e.g. refs/heads/*) leave
1121            // every entry not-for-merge.
1122            if let Some(update) = updates.first_mut() {
1123                update.not_for_merge = false;
1124            }
1125        }
1126        // git's store_updated_refs writes FETCH_HEAD in two passes: all for-merge
1127        // entries first (in ref-map order), then all not-for-merge. Reorder
1128        // stably to reproduce that layout.
1129        updates.sort_by_key(|update| update.not_for_merge);
1130    }
1131    let opportunistic_dsts =
1132        append_opportunistic_tracking_updates(&mut updates, tracking_refspecs)?;
1133    ref_remove_duplicate_updates(&mut updates)?;
1134    Ok((updates, opportunistic_dsts))
1135}
1136
1137/// Mirror git's `ref_remove_duplicates` (remote.c): two ref-map entries with the
1138/// same destination are collapsed when they came from the same source ref (e.g.
1139/// a remote that lists `+refs/heads/*:refs/remotes/origin/*` twice), and rejected
1140/// when two *different* sources would map to one destination.
1141fn ref_remove_duplicate_updates(updates: &mut Vec<FetchRefUpdate>) -> Result<()> {
1142    let mut seen: BTreeMap<String, String> = BTreeMap::new();
1143    let mut error = None;
1144    updates.retain(|update| {
1145        let Some(dst) = update.dst.as_deref() else {
1146            return true;
1147        };
1148        match seen.get(dst) {
1149            Some(prev_src) if prev_src == &update.src => false,
1150            Some(prev_src) => {
1151                if error.is_none() {
1152                    error = Some(GitError::Command(format!(
1153                        "Cannot fetch both {} and {} to {dst}",
1154                        prev_src, update.src
1155                    )));
1156                }
1157                true
1158            }
1159            None => {
1160                seen.insert(dst.to_string(), update.src.clone());
1161                true
1162            }
1163        }
1164    });
1165    match error {
1166        Some(err) => Err(err),
1167        None => Ok(()),
1168    }
1169}
1170
1171fn configured_refspecs_for_tracking(config: &GitConfig, remote: &str) -> Vec<String> {
1172    if remote_exists(config, remote) {
1173        remote_config_values(config, remote, "fetch")
1174    } else {
1175        Vec::new()
1176    }
1177}
1178
1179/// Append the opportunistic remote-tracking updates for a command-line refspec
1180/// fetch (a fetched ref that also matches a configured tracking refspec). Returns
1181/// the set of destinations these added — git marks them `FETCH_HEAD_IGNORE`, so
1182/// the caller excludes them from `FETCH_HEAD` while still applying them as refs.
1183fn append_opportunistic_tracking_updates(
1184    updates: &mut Vec<FetchRefUpdate>,
1185    tracking_refspecs: &[RefSpec],
1186) -> Result<HashSet<String>> {
1187    let mut opportunistic_dsts = HashSet::new();
1188    if tracking_refspecs.is_empty() {
1189        return Ok(opportunistic_dsts);
1190    }
1191    let mut seen_dsts = updates
1192        .iter()
1193        .filter_map(|update| update.dst.clone())
1194        .collect::<HashSet<_>>();
1195    let mut additions = Vec::new();
1196    for update in updates.iter() {
1197        if fetch_refspec_excludes(tracking_refspecs, &update.src)? {
1198            continue;
1199        }
1200        for refspec in tracking_refspecs.iter().filter(|refspec| !refspec.negative) {
1201            let Some(dst) = refspec_map_source(refspec, &update.src)? else {
1202                continue;
1203            };
1204            if !seen_dsts.insert(dst.clone()) {
1205                continue;
1206            }
1207            opportunistic_dsts.insert(dst.clone());
1208            additions.push(FetchRefUpdate {
1209                src: update.src.clone(),
1210                dst: Some(dst),
1211                oid: update.oid,
1212                not_for_merge: true,
1213                force: refspec.force,
1214            });
1215        }
1216    }
1217    updates.extend(additions);
1218    Ok(opportunistic_dsts)
1219}
1220
1221fn advertisements_without_peeled_refs(
1222    advertisements: &[RefAdvertisement],
1223) -> Vec<RefAdvertisement> {
1224    advertisements
1225        .iter()
1226        .filter(|advertisement| !advertisement.name.ends_with("^{}"))
1227        .cloned()
1228        .collect()
1229}
1230
1231fn append_missing_ext_advertised_tags(
1232    advertisements: &[RefAdvertisement],
1233    refspecs: &[RefSpec],
1234    store: &FileRefStore,
1235    updates: &mut Vec<FetchRefUpdate>,
1236) -> Result<()> {
1237    let mut seen = updates
1238        .iter()
1239        .map(|update| update.src.clone())
1240        .collect::<HashSet<_>>();
1241    let mut tags = Vec::new();
1242    for reference in advertisements {
1243        if !reference.name.starts_with("refs/tags/")
1244            || reference.name.ends_with("^{}")
1245            || !seen.insert(reference.name.clone())
1246            || fetch_refspec_excludes(refspecs, &reference.name)?
1247            || store.read_ref(&reference.name)?.is_some()
1248        {
1249            continue;
1250        }
1251        tags.push(FetchRefUpdate {
1252            src: reference.name.clone(),
1253            dst: Some(reference.name.clone()),
1254            oid: reference.oid,
1255            not_for_merge: true,
1256            force: false,
1257        });
1258    }
1259    tags.sort_by(|a, b| a.src.cmp(&b.src));
1260    updates.extend(tags);
1261    Ok(())
1262}
1263
1264/// Write `FETCH_HEAD`, apply the remote-tracking ref updates, and record the
1265/// applied updates in `outcome`. A no-op on `dry_run` (the pack is already
1266/// installed; refs and `FETCH_HEAD` are left untouched), matching the CLI.
1267struct FetchFinalize<'a> {
1268    git_dir: &'a Path,
1269    format: ObjectFormat,
1270    store: &'a FileRefStore,
1271    options: &'a FetchOptions,
1272    fetch_head_source: &'a str,
1273    default_head_fetch: bool,
1274    log_all_ref_updates: bool,
1275    ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
1276    /// Destinations of opportunistic tracking updates (git's `FETCH_HEAD_IGNORE`):
1277    /// applied as refs but excluded from `FETCH_HEAD`.
1278    opportunistic_dsts: &'a HashSet<String>,
1279}
1280
1281/// git's `store_updated_refs` (builtin/fetch.c) downgrades any for-merge
1282/// FETCH_HEAD entry whose object does not peel to a commit to not-for-merge: an
1283/// explicit `tag <name>` whose tag points at a tree or blob (e.g. `tag-one-tree`)
1284/// is recorded but never eligible for merge. Runs after the pack is installed so
1285/// the objects are present locally.
1286fn downgrade_non_commit_for_merge(
1287    git_dir: &Path,
1288    format: ObjectFormat,
1289    updates: &mut [FetchRefUpdate],
1290) {
1291    if updates.iter().all(|update| update.not_for_merge) {
1292        return;
1293    }
1294    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1295    for update in updates.iter_mut() {
1296        if !update.not_for_merge && sley_rev::peel_to_commit(&db, format, &update.oid).is_err() {
1297            update.not_for_merge = true;
1298        }
1299    }
1300}
1301
1302fn finalize_fetch(
1303    finalize: FetchFinalize<'_>,
1304    updates: &mut Vec<FetchRefUpdate>,
1305    outcome: &mut FetchOutcome,
1306) -> Result<()> {
1307    let FetchFinalize {
1308        git_dir,
1309        format,
1310        store,
1311        options,
1312        fetch_head_source,
1313        default_head_fetch,
1314        log_all_ref_updates,
1315        ref_hook,
1316        opportunistic_dsts,
1317    } = finalize;
1318    if options.dry_run {
1319        outcome.ref_updates = std::mem::take(updates);
1320        return Ok(());
1321    }
1322    downgrade_non_commit_for_merge(git_dir, format, updates);
1323    validate_fetch_ref_updates(git_dir, format, store, options.update_head_ok, updates)?;
1324    if options.atomic {
1325        // Atomic fetch (`do_fetch`/`store_updated_refs` with a transaction): a
1326        // single rejected update aborts the whole fetch and leaves `FETCH_HEAD`
1327        // empty. git truncates `FETCH_HEAD` up front (unless `--append`) and
1328        // only re-writes the buffered records once the transaction commits, so
1329        // an abort leaves the truncated (empty) file. Reject non-fast-forward
1330        // tracking updates first, then apply every update in one transaction
1331        // (firing the `reference-transaction` hook, which may itself abort).
1332        if options.write_fetch_head && !options.append {
1333            fs::write(git_dir.join("FETCH_HEAD"), b"")?;
1334        }
1335        if let Some(reason) = atomic_non_fast_forward_rejection(git_dir, format, store, updates)? {
1336            return Err(GitError::Command(reason));
1337        }
1338        apply_fetch_ref_updates(
1339            store,
1340            format,
1341            fetch_head_source,
1342            log_all_ref_updates,
1343            updates,
1344            ref_hook,
1345        )?;
1346        if options.write_fetch_head {
1347            // Already truncated above when not appending, so always append the
1348            // committed records (mirrors git's buffer-then-`commit_fetch_head`).
1349            write_finalized_fetch_head(
1350                git_dir,
1351                fetch_head_source,
1352                default_head_fetch,
1353                updates,
1354                opportunistic_dsts,
1355                true,
1356            )?;
1357            outcome.wrote_fetch_head = true;
1358        }
1359        outcome.ref_updates = std::mem::take(updates);
1360        return Ok(());
1361    }
1362    if options.write_fetch_head {
1363        write_finalized_fetch_head(
1364            git_dir,
1365            fetch_head_source,
1366            default_head_fetch,
1367            updates,
1368            opportunistic_dsts,
1369            options.append,
1370        )?;
1371        outcome.wrote_fetch_head = true;
1372    }
1373    apply_fetch_ref_updates(
1374        store,
1375        format,
1376        fetch_head_source,
1377        log_all_ref_updates,
1378        updates,
1379        ref_hook,
1380    )?;
1381    outcome.ref_updates = std::mem::take(updates);
1382    Ok(())
1383}
1384
1385/// Write `FETCH_HEAD` for the planned `updates`, using the bare-`HEAD` default
1386/// record when the fetch was a single default `HEAD` fetch. Opportunistic
1387/// tracking updates (git's `FETCH_HEAD_IGNORE`) are dropped — they are applied
1388/// as refs but not recorded in `FETCH_HEAD`.
1389fn write_finalized_fetch_head(
1390    git_dir: &Path,
1391    fetch_head_source: &str,
1392    default_head_fetch: bool,
1393    updates: &[FetchRefUpdate],
1394    opportunistic_dsts: &HashSet<String>,
1395    append: bool,
1396) -> Result<()> {
1397    if default_head_fetch
1398        && updates.len() == 1
1399        && updates[0].src == "HEAD"
1400        && updates[0].dst.is_none()
1401    {
1402        return write_default_fetch_head(git_dir, fetch_head_source, updates[0].oid, append);
1403    }
1404    let records: Vec<FetchRefUpdate> = updates
1405        .iter()
1406        .filter(|update| {
1407            update
1408                .dst
1409                .as_deref()
1410                .is_none_or(|dst| !opportunistic_dsts.contains(dst))
1411        })
1412        .cloned()
1413        .collect();
1414    write_fetch_head(git_dir, fetch_head_source, &records, append)
1415}
1416
1417/// Reject the first non-fast-forward tracking update an `--atomic` fetch would
1418/// make (a non-forced refspec whose destination already exists and whose new tip
1419/// does not descend from the old). Returns the git-shaped `! [rejected]` line so
1420/// the whole atomic transaction can be aborted before any ref is touched.
1421fn atomic_non_fast_forward_rejection(
1422    git_dir: &Path,
1423    format: ObjectFormat,
1424    store: &FileRefStore,
1425    updates: &[FetchRefUpdate],
1426) -> Result<Option<String>> {
1427    let mut db: Option<FileObjectDatabase> = None;
1428    for update in updates {
1429        let Some(dst) = update.dst.as_deref() else {
1430            continue;
1431        };
1432        if update.force {
1433            continue;
1434        }
1435        let Some(RefTarget::Direct(old)) = store.read_ref(dst)? else {
1436            continue;
1437        };
1438        if old == update.oid || dst.starts_with("refs/tags/") {
1439            continue;
1440        }
1441        let db = db.get_or_insert_with(|| FileObjectDatabase::from_git_dir(git_dir, format));
1442        if !crate::push::is_fast_forward(git_dir, db, format, &old, &update.oid)? {
1443            return Ok(Some(format!(
1444                "! [rejected]        {} -> {}  (non-fast-forward)",
1445                update.src, dst
1446            )));
1447        }
1448    }
1449    Ok(None)
1450}
1451
1452fn apply_fetch_ref_updates(
1453    store: &FileRefStore,
1454    format: ObjectFormat,
1455    fetch_head_source: &str,
1456    log_all_ref_updates: bool,
1457    updates: &[FetchRefUpdate],
1458    ref_hook: Option<&dyn sley_refs::ReferenceTransactionHook>,
1459) -> Result<()> {
1460    let mut seen = BTreeSet::new();
1461    let mut tx = store.transaction();
1462    if let Some(hook) = ref_hook {
1463        tx = tx.with_hook(hook);
1464    }
1465    for update in updates {
1466        let Some(dst) = update.dst.as_deref() else {
1467            continue;
1468        };
1469        if !seen.insert(dst.to_string()) {
1470            return Err(GitError::Transaction(format!("duplicate fetch ref {dst}")));
1471        }
1472        let old_oid = match store.read_ref(dst)? {
1473            Some(RefTarget::Direct(oid)) => Some(oid),
1474            Some(RefTarget::Symbolic(target)) => {
1475                return Err(GitError::Transaction(format!(
1476                    "fetch ref {dst} would overwrite symbolic ref {target}"
1477                )));
1478            }
1479            None => None,
1480        };
1481        let reflog = if log_all_ref_updates && fetch_should_write_reflog(dst) {
1482            Some(ReflogEntry {
1483                old_oid: old_oid.unwrap_or_else(|| ObjectId::null(format)),
1484                new_oid: update.oid,
1485                committer: fetch_reflog_committer(),
1486                message: fetch_reflog_message(fetch_head_source, update, old_oid.is_some()),
1487            })
1488        } else {
1489            None
1490        };
1491        tx.update(RefUpdate {
1492            name: dst.to_string(),
1493            expected: old_oid.map(RefTarget::Direct),
1494            new: RefTarget::Direct(update.oid),
1495            reflog,
1496        });
1497    }
1498    tx.commit()
1499}
1500
1501/// Effective fetch pack input cap, mirroring git's `fetch.maxInputSize` with
1502/// `transfer.maxSize` as the fallback when the fetch-specific key is unset.
1503pub fn fetch_max_input_size(config: &GitConfig) -> Option<u64> {
1504    config_max_input_size(config, "fetch", "maxInputSize")
1505        .or_else(|| config_max_input_size(config, "transfer", "maxSize"))
1506}
1507
1508fn config_max_input_size(config: &GitConfig, section: &str, key: &str) -> Option<u64> {
1509    let raw = config.get(section, None, key)?;
1510    match sley_config::parse_config_int(raw) {
1511        Some(limit) if limit > 0 => Some(limit as u64),
1512        _ => None,
1513    }
1514}
1515
1516fn fetch_log_all_ref_updates(config: &GitConfig) -> bool {
1517    match config.get("core", None, "logallrefupdates") {
1518        Some(value) => {
1519            let value = value.to_ascii_lowercase();
1520            matches!(value.as_str(), "true" | "yes" | "on" | "1" | "always")
1521        }
1522        None => false,
1523    }
1524}
1525
1526fn fetch_should_write_reflog(refname: &str) -> bool {
1527    refname == "HEAD"
1528        || refname.starts_with("refs/heads/")
1529        || refname.starts_with("refs/remotes/")
1530        || refname.starts_with("refs/notes/")
1531}
1532
1533fn fetch_reflog_committer() -> Vec<u8> {
1534    let seconds = SystemTime::now()
1535        .duration_since(UNIX_EPOCH)
1536        .map(|duration| duration.as_secs())
1537        .unwrap_or(0);
1538    format!("Git Rs <sley@example.invalid> {seconds} +0000").into_bytes()
1539}
1540
1541fn fetch_reflog_message(source: &str, update: &FetchRefUpdate, old_exists: bool) -> Vec<u8> {
1542    let src = fetch_reflog_short_ref(&update.src);
1543    let dst = update
1544        .dst
1545        .as_deref()
1546        .map(fetch_reflog_short_ref)
1547        .unwrap_or_else(|| update.src.clone());
1548    let action = if !old_exists {
1549        if update.src.starts_with("refs/tags/") {
1550            "storing tag"
1551        } else if update.src.starts_with("refs/heads/") {
1552            "storing head"
1553        } else {
1554            "storing ref"
1555        }
1556    } else if update.force {
1557        "forced-update"
1558    } else if update.src.starts_with("refs/tags/") {
1559        "updating tag"
1560    } else {
1561        "fast-forward"
1562    };
1563    format!("fetch {source} {src}:{dst}: {action}").into_bytes()
1564}
1565
1566fn fetch_reflog_short_ref(refname: &str) -> String {
1567    for prefix in ["refs/heads/", "refs/tags/", "refs/remotes/"] {
1568        if let Some(short) = refname.strip_prefix(prefix) {
1569            return short.to_string();
1570        }
1571    }
1572    refname.to_string()
1573}
1574
1575fn validate_fetch_ref_updates(
1576    git_dir: &Path,
1577    _format: ObjectFormat,
1578    store: &FileRefStore,
1579    update_head_ok: bool,
1580    updates: &[FetchRefUpdate],
1581) -> Result<()> {
1582    for update in updates {
1583        let Some(dst) = update.dst.as_deref() else {
1584            continue;
1585        };
1586        let old = match store.read_ref(dst)? {
1587            Some(RefTarget::Direct(oid)) => Some(oid),
1588            Some(RefTarget::Symbolic(target)) => {
1589                return Err(GitError::Transaction(format!(
1590                    "ref {dst} would overwrite symbolic ref {target}"
1591                )));
1592            }
1593            None => None,
1594        };
1595        if old.is_some()
1596            && !update_head_ok
1597            && dst.starts_with("refs/heads/")
1598            && let Some(worktree) = sley_worktree::find_shared_symref(git_dir, "HEAD", dst)?
1599        {
1600            return Err(GitError::InvalidFormat(format!(
1601                "fatal: refusing to fetch into branch '{dst}' checked out at '{}'",
1602                worktree.path.display()
1603            )));
1604        }
1605        if old.is_some()
1606            && old != Some(update.oid)
1607            && dst.starts_with("refs/tags/")
1608            && !update.force
1609        {
1610            return Err(GitError::Command(format!(
1611                "! [rejected]        {} -> {}  (would clobber existing tag)",
1612                update.src, dst
1613            )));
1614        }
1615    }
1616    Ok(())
1617}
1618
1619/// The remote's advertised `HEAD` symref target (`HEAD:<target>` capability).
1620fn head_symref_from_features(symrefs: &[String]) -> Option<String> {
1621    symrefs
1622        .iter()
1623        .find_map(|entry| entry.strip_prefix("HEAD:").map(|target| target.to_string()))
1624}
1625
1626fn reject_shallow_clone_fetch(
1627    options: &FetchOptions,
1628    shallow_info: &[sley_protocol::ProtocolV2FetchShallowInfo],
1629) -> Result<()> {
1630    // Upstream fetch-pack sets `args->deepen` when ANY deepen argument is present
1631    // (depth, deepen-since, or deepen-not) and only dies on a shallow source when
1632    // no deepen was requested (the shallow lines are then attributed to the remote
1633    // being shallow, not to our own depth request). Mirror that gate here.
1634    let deepening = options.depth.is_some()
1635        || options.deepen_since.is_some()
1636        || !options.deepen_not.is_empty();
1637    if options.reject_shallow && options.cloning && !deepening && !shallow_info.is_empty() {
1638        eprintln!("fatal: source repository is shallow, reject to clone.");
1639        return Err(GitError::Exit(128));
1640    }
1641    Ok(())
1642}
1643
1644fn custom_negotiation_haves(
1645    git_dir: &Path,
1646    format: ObjectFormat,
1647    config: &GitConfig,
1648    remote: &str,
1649    options: &FetchOptions,
1650) -> Result<Option<Vec<ObjectId>>> {
1651    let restrict = match &options.negotiation_restrict {
1652        Some(values) => values.clone(),
1653        None => configured_negotiation_values(config, remote, "negotiationrestrict"),
1654    };
1655    let include = match &options.negotiation_include {
1656        Some(values) => values.clone(),
1657        None => configured_negotiation_values(config, remote, "negotiationinclude"),
1658    };
1659    if restrict.is_empty() && include.is_empty() {
1660        return Ok(None);
1661    }
1662
1663    let store = FileRefStore::new(git_dir, format);
1664    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1665    let mut seen = HashSet::new();
1666    let mut haves = Vec::new();
1667    if restrict.is_empty() {
1668        for oid in crate::local::local_have_oids(git_dir, format)? {
1669            push_have_oid(&mut haves, &mut seen, oid);
1670        }
1671    } else {
1672        for value in &restrict {
1673            for oid in resolve_negotiation_have_value(git_dir, format, &store, &db, value)? {
1674                push_have_oid(&mut haves, &mut seen, oid);
1675            }
1676        }
1677    }
1678    for value in &include {
1679        for oid in resolve_negotiation_have_value(git_dir, format, &store, &db, value)? {
1680            push_have_oid(&mut haves, &mut seen, oid);
1681        }
1682    }
1683    Ok(Some(haves))
1684}
1685
1686fn configured_negotiation_values(config: &GitConfig, remote: &str, key: &str) -> Vec<String> {
1687    let mut values = Vec::new();
1688    if !remote_exists(config, remote) {
1689        return values;
1690    }
1691    for value in remote_config_values(config, remote, key) {
1692        if value.is_empty() {
1693            values.clear();
1694        } else {
1695            values.push(value);
1696        }
1697    }
1698    values
1699}
1700
1701fn push_have_oid(haves: &mut Vec<ObjectId>, seen: &mut HashSet<ObjectId>, oid: ObjectId) {
1702    if seen.insert(oid) {
1703        haves.push(oid);
1704    }
1705}
1706
1707fn resolve_negotiation_have_value(
1708    git_dir: &Path,
1709    format: ObjectFormat,
1710    store: &FileRefStore,
1711    db: &FileObjectDatabase,
1712    value: &str,
1713) -> Result<Vec<ObjectId>> {
1714    if has_glob(value) {
1715        let mut out = Vec::new();
1716        for reference in store.list_refs()? {
1717            let RefTarget::Direct(oid) = reference.target else {
1718                continue;
1719            };
1720            if negotiation_pattern_matches(value, &reference.name) {
1721                out.push(oid);
1722            }
1723        }
1724        out.sort();
1725        out.dedup();
1726        return Ok(out);
1727    }
1728    if is_full_hex_oid(format, value) {
1729        let oid = ObjectId::from_hex(format, value)?;
1730        return if db.contains(&oid)? {
1731            Ok(vec![oid])
1732        } else {
1733            Err(GitError::InvalidFormat(format!(
1734                "fatal: the object {oid} does not exist"
1735            )))
1736        };
1737    }
1738    match sley_rev::resolve_revision(git_dir, format, value) {
1739        Ok(oid) => Ok(vec![oid]),
1740        Err(_) => Ok(Vec::new()),
1741    }
1742}
1743
1744fn has_glob(value: &str) -> bool {
1745    value.as_bytes().iter().any(|byte| matches!(byte, b'*' | b'?'))
1746}
1747
1748fn is_full_hex_oid(format: ObjectFormat, value: &str) -> bool {
1749    value.len() == format.hex_len() && value.as_bytes().iter().all(u8::is_ascii_hexdigit)
1750}
1751
1752fn negotiation_pattern_matches(pattern: &str, refname: &str) -> bool {
1753    glob_match(pattern, refname)
1754        || ["refs/heads/", "refs/tags/", "refs/remotes/"]
1755            .iter()
1756            .filter_map(|prefix| refname.strip_prefix(prefix))
1757            .any(|short| glob_match(pattern, short))
1758}
1759
1760fn glob_match(pattern: &str, text: &str) -> bool {
1761    glob_match_bytes(pattern.as_bytes(), text.as_bytes())
1762}
1763
1764fn glob_match_bytes(pattern: &[u8], text: &[u8]) -> bool {
1765    let (mut p, mut t) = (0, 0);
1766    let mut star = None;
1767    let mut match_after_star = 0;
1768    while t < text.len() {
1769        if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == text[t]) {
1770            p += 1;
1771            t += 1;
1772        } else if p < pattern.len() && pattern[p] == b'*' {
1773            star = Some(p);
1774            p += 1;
1775            match_after_star = t;
1776        } else if let Some(star_pos) = star {
1777            p = star_pos + 1;
1778            match_after_star += 1;
1779            t = match_after_star;
1780        } else {
1781            return false;
1782        }
1783    }
1784    while p < pattern.len() && pattern[p] == b'*' {
1785        p += 1;
1786    }
1787    p == pattern.len()
1788}
1789
1790/// Apply `remote.<name>.partialclonefilter` when `remote.<name>.promisor` is set.
1791pub fn apply_configured_partial_clone_filter(
1792    config: &GitConfig,
1793    remote: &str,
1794    options: &mut FetchOptions,
1795) {
1796    if config
1797        .get_bool("remote", Some(remote), "promisor")
1798        .unwrap_or(false)
1799        && let Some(filter) = config.get("remote", Some(remote), "partialclonefilter")
1800    {
1801        options.filter = crate::pack_filter_from_spec(filter);
1802    }
1803}
1804
1805/// Apply the configured `remote.<name>.tagopt` unless the tag option was set
1806/// explicitly on the command line.
1807pub fn apply_configured_remote_tag_option(
1808    config: &GitConfig,
1809    source: &str,
1810    options: &mut FetchOptions,
1811) {
1812    if options.tag_option_explicit || !remote_exists(config, source) {
1813        return;
1814    }
1815    match remote_config_values(config, source, "tagopt")
1816        .into_iter()
1817        .last()
1818        .as_deref()
1819    {
1820        Some("--tags") => {
1821            options.auto_follow_tags = true;
1822            options.fetch_all_tags = true;
1823        }
1824        Some("--no-tags") => {
1825            options.auto_follow_tags = false;
1826            options.fetch_all_tags = false;
1827        }
1828        _ => {}
1829    }
1830}
1831
1832/// Apply the configured `remote.<name>.prune` (then `fetch.prune`) unless the
1833/// prune option was set explicitly on the command line.
1834pub fn apply_configured_fetch_prune_option(
1835    config: &GitConfig,
1836    source: &str,
1837    options: &mut FetchOptions,
1838) {
1839    if !options.prune_option_explicit {
1840        if let Some(prune) = config.get_bool("remote", Some(source), "prune") {
1841            options.prune = prune;
1842        } else if let Some(prune) = config.get_bool("fetch", None, "prune") {
1843            options.prune = prune;
1844        }
1845    }
1846    if !options.prune_tags_option_explicit {
1847        if let Some(prune_tags) = config.get_bool("remote", Some(source), "prunetags") {
1848            options.prune_tags = prune_tags;
1849        } else if let Some(prune_tags) = config.get_bool("fetch", None, "prunetags") {
1850            options.prune_tags = prune_tags;
1851        }
1852    }
1853}
1854
1855/// The effective refspec list for a fetch: explicit `refspecs`, else the
1856/// `configured` remote refspecs, else `HEAD`; with `refs/tags/*` appended when
1857/// fetching all tags.
1858pub fn fetch_refspecs_for_source(
1859    configured: Vec<String>,
1860    refspecs: &[String],
1861    fetch_all_tags: bool,
1862) -> Vec<String> {
1863    let mut effective = if !refspecs.is_empty() {
1864        refspecs.to_vec()
1865    } else if configured.is_empty() {
1866        vec!["HEAD".to_string()]
1867    } else {
1868        configured
1869    };
1870    if fetch_all_tags {
1871        effective.push("refs/tags/*:refs/tags/*".to_string());
1872    }
1873    effective
1874}
1875
1876fn prune_refspecs_for_source(
1877    configured: &[String],
1878    refspecs: &[String],
1879    prune_tags: bool,
1880) -> Vec<String> {
1881    let mut effective = if !refspecs.is_empty() {
1882        refspecs.to_vec()
1883    } else {
1884        configured.to_vec()
1885    };
1886    if prune_tags && refspecs.is_empty() {
1887        effective.push("refs/tags/*:refs/tags/*".to_string());
1888    }
1889    effective
1890}
1891
1892/// Whether a refspec (with source `src`) already covers `merge_src` — the test
1893/// `add_merge_config` makes before fetching a `branch.<x>.merge` ref separately.
1894/// A pattern source (`refs/heads/*`) covers any ref whose name fits the
1895/// prefix/suffix; a literal source matches by git's abbreviated `refname_match`.
1896fn refspec_source_covers(refspec: &RefSpec, src: &str, merge_src: &str) -> bool {
1897    if refspec.pattern {
1898        let Some((prefix, suffix)) = src.split_once('*') else {
1899            return false;
1900        };
1901        // A `branch.<x>.merge` value may be abbreviated (`two` for
1902        // `refs/heads/two`); git's `refname_match` resolves it against the
1903        // ref-map entry the glob produced. Test the merge ref both verbatim and
1904        // qualified under `refs/heads/`, the namespace branch merges live in.
1905        let fits = |name: &str| {
1906            name.len() >= prefix.len() + suffix.len()
1907                && name.starts_with(prefix)
1908                && name.ends_with(suffix)
1909        };
1910        fits(merge_src) || fits(&format!("refs/heads/{merge_src}"))
1911    } else {
1912        refname_matches(merge_src, src) || refname_matches(src, merge_src)
1913    }
1914}
1915
1916/// Mark tag refspec updates (`refs/tags/X:refs/tags/X`) as not-for-merge.
1917pub fn mark_tag_refspec_updates_not_for_merge(updates: &mut [FetchRefUpdate]) {
1918    for update in updates {
1919        if update.src.starts_with("refs/tags/") && update.dst.as_deref() == Some(&update.src) {
1920            update.not_for_merge = true;
1921        }
1922    }
1923}
1924
1925/// Drop auto-followed tags that already exist locally, keeping only missing ones.
1926pub fn retain_missing_auto_follow_tags(
1927    store: &FileRefStore,
1928    updates: &mut Vec<FetchRefUpdate>,
1929) -> Result<()> {
1930    let mut retained = Vec::with_capacity(updates.len());
1931    for update in updates.drain(..) {
1932        if update.not_for_merge
1933            && update.src.starts_with("refs/tags/")
1934            && update.dst.as_deref() == Some(&update.src)
1935            && store.read_ref(&update.src)?.is_some()
1936        {
1937            continue;
1938        }
1939        retained.push(update);
1940    }
1941    *updates = retained;
1942    Ok(())
1943}
1944
1945/// Append tags reachable from the fetched (non-tag) commits, using the remote
1946/// object database to test reachability.
1947pub fn append_reachable_auto_follow_tags(
1948    advertisements: &[RefAdvertisement],
1949    remote_db: &FileObjectDatabase,
1950    local_db: Option<&FileObjectDatabase>,
1951    format: ObjectFormat,
1952    refspecs: &[RefSpec],
1953    updates: &mut Vec<FetchRefUpdate>,
1954    deepen_excluded: Option<&HashSet<ObjectId>>,
1955) -> Result<()> {
1956    if !updates.iter().any(|update| update.dst.is_some()) {
1957        return Ok(());
1958    }
1959    // Drop any auto-follow tag entries the shared planner added: when we have the
1960    // remote object database we are the authoritative tag follower (we peel
1961    // annotated tags) and we re-add the full set sorted by refname, mirroring
1962    // git's `find_non_local_tags`, which inserts into a sorted string-list.
1963    updates.retain(|update| {
1964        !(update.src.starts_with("refs/tags/")
1965            && update.dst.as_deref() == Some(update.src.as_str())
1966            && update.not_for_merge)
1967    });
1968    // Reachability seeds are every object we're fetching (git's `fetch_oids`):
1969    // non-tag tips directly, and tag updates by their peeled target so an
1970    // explicitly-requested `tag <name>` still seeds the auto-follow of its
1971    // siblings.
1972    let mut starts = Vec::new();
1973    for update in updates.iter().filter(|update| update.dst.is_some()) {
1974        if update.src.starts_with("refs/tags/") {
1975            if let Some(target) = peel_tag_target(remote_db, format, &update.oid)? {
1976                starts.push(target);
1977            } else {
1978                starts.push(update.oid);
1979            }
1980        } else {
1981            starts.push(update.oid);
1982        }
1983    }
1984    // A deepen fetch must not auto-follow tags past the shallow boundary: only
1985    // tags whose target lands in the truncated pack are followed (upstream's
1986    // include-tag packs a tag only when its referenced object is packed).
1987    let reachable = match deepen_excluded {
1988        Some(excluded) => {
1989            collect_reachable_object_ids_excluding(remote_db, format, starts, excluded)?
1990        }
1991        None => collect_reachable_object_ids(remote_db, format, starts)?,
1992    };
1993    let fetched_srcs = updates
1994        .iter()
1995        .map(|update| update.src.clone())
1996        .collect::<HashSet<_>>();
1997    let mut followed = Vec::new();
1998    for reference in advertisements {
1999        if !reference.name.starts_with("refs/tags/")
2000            || fetched_srcs.contains(&reference.name)
2001            || fetch_refspec_excludes(refspecs, &reference.name)?
2002        {
2003            continue;
2004        }
2005        // A tag is auto-followed when the object it ultimately points at is
2006        // either among the objects being fetched (reachable from a fetched tip)
2007        // or already present in the local object database (git's
2008        // `find_non_local_tags`: `oidset_contains(fetch_oids) || odb_has_object`).
2009        // For lightweight tags the target is the advertised oid; for annotated
2010        // tags it is the peeled target (the tag object is never reachable from a
2011        // commit, so peel through the chain).
2012        let target = peel_tag_target(remote_db, format, &reference.oid)?.unwrap_or(reference.oid);
2013        let fetched = reachable.contains(&reference.oid) || reachable.contains(&target);
2014        let present_locally = local_db
2015            .map(|db| db.contains(&target))
2016            .transpose()?
2017            .unwrap_or(false);
2018        if !fetched && !present_locally {
2019            continue;
2020        }
2021        followed.push(FetchRefUpdate {
2022            src: reference.name.clone(),
2023            dst: Some(reference.name.clone()),
2024            oid: reference.oid,
2025            not_for_merge: true,
2026            force: false,
2027        });
2028    }
2029    followed.sort_by(|a, b| a.src.cmp(&b.src));
2030    updates.extend(followed);
2031    Ok(())
2032}
2033
2034/// Peel an annotated-tag object to the non-tag object it ultimately references,
2035/// following nested tag chains. Returns `None` if `oid` is not an annotated tag
2036/// (a lightweight tag points directly at its target, already the advertised oid)
2037/// or cannot be read from `db`.
2038fn peel_tag_target(
2039    db: &FileObjectDatabase,
2040    format: ObjectFormat,
2041    oid: &ObjectId,
2042) -> Result<Option<ObjectId>> {
2043    let mut current = *oid;
2044    let mut peeled = None;
2045    loop {
2046        let Ok(object) = db.read_object(&current) else {
2047            return Ok(peeled);
2048        };
2049        if object.object_type != sley_object::ObjectType::Tag {
2050            return Ok(peeled);
2051        }
2052        let tag = sley_object::Tag::parse(format, &object.body)?;
2053        current = tag.object;
2054        peeled = Some(current);
2055    }
2056}
2057
2058/// Whether any negative refspec excludes `name`.
2059pub fn fetch_refspec_excludes(refspecs: &[RefSpec], name: &str) -> Result<bool> {
2060    for refspec in refspecs.iter().filter(|refspec| refspec.negative) {
2061        if refspec.pattern {
2062            if refspec_map_source(refspec, name)?.is_some() {
2063                return Ok(true);
2064            }
2065        } else if refspec.src.as_deref() == Some(name) {
2066            return Ok(true);
2067        }
2068    }
2069    Ok(false)
2070}
2071
2072/// Reorder updates so a bundle `--tags` fetch lists non-tags, then tags pointing
2073/// at fetched commits, then the remaining tags (matching git's ordering).
2074pub fn order_bundle_fetch_all_tags_updates(updates: &mut Vec<FetchRefUpdate>) {
2075    let followed_oids = updates
2076        .iter()
2077        .filter(|update| !update.src.starts_with("refs/tags/") && update.dst.is_some())
2078        .map(|update| update.oid)
2079        .collect::<HashSet<_>>();
2080    if followed_oids.is_empty() {
2081        return;
2082    }
2083
2084    let mut non_tags = Vec::new();
2085    let mut followed_tags = Vec::new();
2086    let mut other_tags = Vec::new();
2087    for update in updates.drain(..) {
2088        if update.src.starts_with("refs/tags/") {
2089            if followed_oids.contains(&update.oid) {
2090                followed_tags.push(update);
2091            } else {
2092                other_tags.push(update);
2093            }
2094        } else {
2095            non_tags.push(update);
2096        }
2097    }
2098    updates.extend(non_tags);
2099    updates.extend(followed_tags);
2100    updates.extend(other_tags);
2101}
2102
2103/// Write a single default `FETCH_HEAD` record (a bare `HEAD` fetch).
2104pub fn write_default_fetch_head(
2105    git_dir: &Path,
2106    source: &str,
2107    oid: ObjectId,
2108    append: bool,
2109) -> Result<()> {
2110    let records = [FetchHeadRecord {
2111        oid,
2112        not_for_merge: false,
2113        description: source.to_string(),
2114    }];
2115    write_fetch_head_records(git_dir, &records, append)?;
2116    Ok(())
2117}
2118
2119/// Write `FETCH_HEAD` records, truncating or appending per `append`.
2120pub fn write_fetch_head_records(
2121    git_dir: &Path,
2122    records: &[FetchHeadRecord],
2123    append: bool,
2124) -> Result<()> {
2125    let encoded = encode_fetch_head(records)?;
2126    if append {
2127        let mut file = fs::OpenOptions::new()
2128            .create(true)
2129            .append(true)
2130            .open(git_dir.join("FETCH_HEAD"))?;
2131        file.write_all(&encoded)?;
2132    } else {
2133        fs::write(git_dir.join("FETCH_HEAD"), encoded)?;
2134    }
2135    Ok(())
2136}
2137
2138/// Write `FETCH_HEAD` from fetched ref updates, describing each by `description`.
2139pub fn write_fetch_head(
2140    git_dir: &Path,
2141    description: &str,
2142    fetched: &[FetchRefUpdate],
2143    append: bool,
2144) -> Result<()> {
2145    let records = fetch_ref_updates_to_fetch_head(fetched, description)?;
2146    write_fetch_head_records(git_dir, &records, append)?;
2147    Ok(())
2148}
2149
2150/// The `FETCH_HEAD` source description for `source`: its configured URL (rewritten
2151/// per `url.<base>.insteadOf`) if any, otherwise the rewritten `source`.
2152pub fn fetch_head_source_description(config: &GitConfig, source: &str) -> String {
2153    let url = remote_config_values(config, source, "url")
2154        .into_iter()
2155        .next()
2156        .map(|url| rewrite_url_with_config(config, &url, false))
2157        .unwrap_or_else(|| rewrite_url_with_config(config, source, false));
2158    redact_url_for_display(&trim_fetch_head_display_url(&url))
2159}
2160
2161/// Mirror git's `display_state` URL trimming (builtin/fetch.c): strip trailing
2162/// slashes and a trailing `.git` so the `FETCH_HEAD` note reads `branch 'x' of
2163/// ../` rather than `branch 'x' of ../.git/`.
2164fn trim_fetch_head_display_url(url: &str) -> String {
2165    let bytes = url.as_bytes();
2166    let mut end = bytes.len();
2167    while end > 0 && bytes[end - 1] == b'/' {
2168        end -= 1;
2169    }
2170    // `end` is the length excluding trailing slashes; git's `i` (index of the
2171    // last non-slash byte) is `end - 1`, and it strips `.git` only when `i > 4`.
2172    if end > 5 && &bytes[end - 4..end] == b".git" {
2173        end -= 4;
2174    }
2175    String::from_utf8_lossy(&bytes[..end]).into_owned()
2176}
2177
2178/// Prune refs whose destinations are covered by the active fetch refspecs and
2179/// whose corresponding remote sources are absent from `advertisements`,
2180/// deleting them and emitting git's notice lines through `progress` (unless
2181/// `quiet`). Returns the refs that were pruned.
2182pub struct PruneRefsInput<'a> {
2183    pub config: &'a GitConfig,
2184    pub store: &'a FileRefStore,
2185    pub remote: &'a str,
2186    pub advertisements: &'a [RefAdvertisement],
2187    pub refspecs: &'a [RefSpec],
2188    pub dry_run: bool,
2189    pub quiet: bool,
2190}
2191
2192pub fn prune_refs_from_advertisements(
2193    input: PruneRefsInput<'_>,
2194    progress: &mut dyn ProgressSink,
2195) -> Result<Vec<PrunedRef>> {
2196    let remote_refs = input
2197        .advertisements
2198        .iter()
2199        .filter(|advertisement| !advertisement.name.ends_with("^{}"))
2200        .map(|advertisement| advertisement.name.as_str())
2201        .collect::<BTreeSet<_>>();
2202    let local_refs = input.store.list_refs()?;
2203    let stale_refs = stale_refs_for_prune(&local_refs, input.refspecs, &remote_refs)?;
2204    if stale_refs.is_empty() {
2205        return Ok(Vec::new());
2206    }
2207    let mut emit = |line: &str| {
2208        if !input.quiet {
2209            progress.message(line);
2210        }
2211    };
2212    let display_url = redact_url_for_display(
2213        &remote_config_values(input.config, input.remote, "url")
2214            .into_iter()
2215            .next()
2216            .unwrap_or_else(|| input.remote.into()),
2217    );
2218    emit(&format!("Pruning {}", input.remote));
2219    emit(&format!("URL: {display_url}"));
2220    let mut pruned = Vec::new();
2221    for refname in stale_refs {
2222        if !input.dry_run {
2223            match input.store.read_ref(&refname)? {
2224                Some(RefTarget::Symbolic(_)) => {
2225                    let _ = input.store.delete_symbolic_ref(&refname)?;
2226                }
2227                Some(RefTarget::Direct(_)) => {
2228                    let _ = input.store.delete_ref(&refname)?;
2229                }
2230                None => {}
2231            }
2232        }
2233        let display = prettify_pruned_ref(input.remote, &refname);
2234        let action = if input.dry_run {
2235            "would prune"
2236        } else {
2237            "pruned"
2238        };
2239        emit(&format!(" * [{action}] {display}"));
2240        let branch = display;
2241        pruned.push(PrunedRef { branch, refname });
2242    }
2243    Ok(pruned)
2244}
2245
2246fn stale_refs_for_prune(
2247    local_refs: &[Ref],
2248    refspecs: &[RefSpec],
2249    remote_refs: &BTreeSet<&str>,
2250) -> Result<Vec<String>> {
2251    let mut stale = Vec::new();
2252    for reference in local_refs {
2253        if matches!(reference.target, RefTarget::Symbolic(_)) {
2254            continue;
2255        }
2256        let sources = prune_sources_for_destination(refspecs, &reference.name)?;
2257        if sources.is_empty() {
2258            continue;
2259        }
2260        if sources
2261            .iter()
2262            .all(|source| !remote_refs.contains(source.as_str()))
2263        {
2264            stale.push(reference.name.clone());
2265        }
2266    }
2267    stale.sort();
2268    Ok(stale)
2269}
2270
2271fn prune_sources_for_destination(refspecs: &[RefSpec], destination: &str) -> Result<Vec<String>> {
2272    let mut sources = Vec::new();
2273    for refspec in refspecs.iter().filter(|refspec| !refspec.negative) {
2274        let Some(src) = refspec.src.as_deref() else {
2275            continue;
2276        };
2277        let Some(dst) = refspec.dst.as_deref() else {
2278            continue;
2279        };
2280        if refspec.pattern {
2281            let Some((dst_prefix, dst_suffix)) = dst.split_once('*') else {
2282                continue;
2283            };
2284            let Some(middle) = destination
2285                .strip_prefix(dst_prefix)
2286                .and_then(|value| value.strip_suffix(dst_suffix))
2287            else {
2288                continue;
2289            };
2290            let (src_prefix, src_suffix) = src.split_once('*').ok_or_else(|| {
2291                GitError::InvalidFormat("pattern refspec source is missing wildcard".into())
2292            })?;
2293            sources.push(format!("{src_prefix}{middle}{src_suffix}"));
2294        } else if dst == destination {
2295            sources.push(src.to_string());
2296        }
2297    }
2298    sources.sort();
2299    sources.dedup();
2300    Ok(sources)
2301}
2302
2303fn prettify_pruned_ref(remote: &str, refname: &str) -> String {
2304    if let Some(branch) = refname.strip_prefix(&format!("refs/remotes/{remote}/")) {
2305        return format!("{remote}/{branch}");
2306    }
2307    if let Some(tag) = refname.strip_prefix("refs/tags/") {
2308        return tag.to_string();
2309    }
2310    refname.to_string()
2311}
2312
2313#[cfg(test)]
2314mod tests {
2315    use super::*;
2316    use std::sync::atomic::{AtomicU64, Ordering};
2317
2318    use sley_config::{ConfigEntry, ConfigSection};
2319    use sley_formats::RepositoryLayout;
2320    use sley_object::{Commit, EncodedObject, ObjectType, Tree};
2321    use sley_odb::{FileObjectDatabase, ObjectWriter};
2322    use sley_refs::{RefTarget, RefUpdate};
2323
2324    use crate::{NoCredentials, SilentProgress};
2325
2326    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
2327
2328    fn temp_repo(name: &str) -> PathBuf {
2329        let dir = std::env::temp_dir().join(format!(
2330            "sley-remote-fetch-{name}-{}-{}",
2331            std::process::id(),
2332            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
2333        ));
2334        let _ = fs::remove_dir_all(&dir);
2335        RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
2336            .expect("test repository should initialize");
2337        dir.join(".git")
2338    }
2339
2340    fn commit_on(git_dir: &Path, branch: &str, message: &str) -> ObjectId {
2341        let format = ObjectFormat::Sha1;
2342        let db = FileObjectDatabase::from_git_dir(git_dir, format);
2343        let tree = db
2344            .write_object(EncodedObject::new(
2345                ObjectType::Tree,
2346                Tree { entries: vec![] }.write(),
2347            ))
2348            .expect("tree should write");
2349        let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
2350        let oid = db
2351            .write_object(EncodedObject::new(
2352                ObjectType::Commit,
2353                Commit {
2354                    tree,
2355                    parents: Vec::new(),
2356                    author: identity.clone(),
2357                    committer: identity,
2358                    encoding: None,
2359                    message: format!("{message}\n").into_bytes(),
2360                }
2361                .write(),
2362            ))
2363            .expect("commit should write");
2364        let store = FileRefStore::new(git_dir, format);
2365        let mut tx = store.transaction();
2366        tx.update(RefUpdate {
2367            name: format!("refs/heads/{branch}"),
2368            expected: None,
2369            new: RefTarget::Direct(oid),
2370            reflog: None,
2371        });
2372        tx.update(RefUpdate {
2373            name: "HEAD".into(),
2374            expected: None,
2375            new: RefTarget::Symbolic(format!("refs/heads/{branch}")),
2376            reflog: None,
2377        });
2378        tx.commit().expect("refs should update");
2379        oid
2380    }
2381
2382    fn default_options() -> FetchOptions {
2383        FetchOptions {
2384            quiet: true,
2385            auto_follow_tags: false,
2386            fetch_all_tags: false,
2387            prune: false,
2388            prune_tags: false,
2389            dry_run: false,
2390            force: false,
2391            append: false,
2392            write_fetch_head: true,
2393            tag_option_explicit: true,
2394            prune_option_explicit: true,
2395            prune_tags_option_explicit: true,
2396            refmap: None,
2397            depth: None,
2398            merge_srcs: Vec::new(),
2399            filter: None,
2400            refetch: false,
2401            cloning: false,
2402            record_promisor_refs: true,
2403            update_shallow: false,
2404            reject_shallow: false,
2405            deepen_relative: false,
2406            update_head_ok: false,
2407            deepen_since: None,
2408            deepen_not: Vec::new(),
2409            ssh_options: None,
2410            atomic: false,
2411            negotiation_restrict: None,
2412            negotiation_include: None,
2413        }
2414    }
2415
2416    #[test]
2417    fn local_fetch_installs_pack_updates_ref_and_fetch_head() {
2418        let remote = temp_repo("remote");
2419        let local = temp_repo("local");
2420        let tip = commit_on(&remote, "main", "remote tip");
2421        let source = FetchSource::Local {
2422            git_dir: remote.clone(),
2423            common_git_dir: remote.clone(),
2424        };
2425        let refspecs = vec!["refs/heads/main:refs/remotes/origin/main".to_string()];
2426        let options = default_options();
2427        let mut credentials = NoCredentials;
2428        let mut progress = SilentProgress;
2429
2430        let outcome = fetch(
2431            FetchRequest {
2432                git_dir: &local,
2433                format: ObjectFormat::Sha1,
2434                config: &GitConfig::default(),
2435                remote_name: "origin",
2436                source: &source,
2437                refspecs: &refspecs,
2438                options: &options,
2439            },
2440            FetchServices {
2441                credentials: &mut credentials,
2442                progress: &mut progress,
2443                ref_hook: None,
2444            },
2445        )
2446        .expect("fetch should succeed");
2447
2448        assert_eq!(outcome.ref_updates.len(), 1);
2449        assert!(outcome.wrote_fetch_head);
2450        let local_db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
2451        assert!(local_db.contains(&tip).expect("contains should read"));
2452        let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
2453        assert_eq!(
2454            local_refs
2455                .read_ref("refs/remotes/origin/main")
2456                .expect("ref should read"),
2457            Some(RefTarget::Direct(tip))
2458        );
2459        let fetch_head = fs::read_to_string(local.join("FETCH_HEAD")).expect("FETCH_HEAD exists");
2460        assert!(fetch_head.contains("origin"));
2461    }
2462
2463    /// An [`HttpClient`] test double that records how many times it was dialed and
2464    /// serves a fixed smart-HTTP `info/refs` advertisement. Standing in for a
2465    /// host's SSRF-guarding client, it proves the fetch/clone HTTP path routes the
2466    /// dial through the injected client rather than constructing a fresh ureq one.
2467    struct RecordingHttpClient {
2468        advertisement: Vec<u8>,
2469        content_type: String,
2470        get_calls: std::sync::atomic::AtomicUsize,
2471        post_calls: std::sync::atomic::AtomicUsize,
2472    }
2473
2474    impl HttpClient for RecordingHttpClient {
2475        fn get(&self, _url: &str, _headers: &[(&str, &str)]) -> Result<sley_transport::HttpResponse> {
2476            self.get_calls
2477                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2478            Ok(sley_transport::HttpResponse {
2479                status: 200,
2480                content_type: Some(self.content_type.clone()),
2481                body: Box::new(std::io::Cursor::new(self.advertisement.clone())),
2482            })
2483        }
2484
2485        fn post(
2486            &self,
2487            _url: &str,
2488            _content_type: &str,
2489            _headers: &[(&str, &str)],
2490            _body: &[u8],
2491        ) -> Result<sley_transport::HttpResponse> {
2492            self.post_calls
2493                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2494            Err(GitError::Command(
2495                "recording client received an unexpected POST".into(),
2496            ))
2497        }
2498    }
2499
2500    #[test]
2501    fn fetch_with_http_client_dials_injected_client() {
2502        use sley_protocol::{
2503            smart_http_advertisement_content_type, GitService, ProtocolVersion, RefAdvertisement,
2504            RefAdvertisementSet,
2505        };
2506        use sley_transport::{
2507            parse_remote_url, write_service_discovery_response, ServiceAnnouncement,
2508            ServiceDiscoveryPayload, ServiceDiscoveryResponse,
2509        };
2510
2511        let local = temp_repo("http-inject");
2512        // Commit the advertised tip locally so the negotiated `want` is already
2513        // present: the fetch then completes after the ref-advertisement GET
2514        // without any pack POST, keeping the test to a single dial.
2515        let tip = commit_on(&local, "main", "already-present tip");
2516
2517        // A protocol-v0 upload-pack advertisement announcing refs/heads/main = tip.
2518        let advertisement = {
2519            let response = ServiceDiscoveryResponse {
2520                announcement: ServiceAnnouncement {
2521                    service: GitService::UploadPack,
2522                },
2523                payload: ServiceDiscoveryPayload::AdvertisedRefs(RefAdvertisementSet {
2524                    protocol: ProtocolVersion::V0,
2525                    refs: vec![RefAdvertisement {
2526                        oid: tip.clone(),
2527                        name: "refs/heads/main".into(),
2528                        capabilities: Vec::new(),
2529                    }],
2530                    shallow: Vec::new(),
2531                }),
2532            };
2533            let mut body = Vec::new();
2534            write_service_discovery_response(&mut body, &response)
2535                .expect("advertisement should encode");
2536            body
2537        };
2538
2539        let client = RecordingHttpClient {
2540            advertisement,
2541            content_type: smart_http_advertisement_content_type(GitService::UploadPack)
2542                .expect("content type"),
2543            get_calls: std::sync::atomic::AtomicUsize::new(0),
2544            post_calls: std::sync::atomic::AtomicUsize::new(0),
2545        };
2546
2547        let source =
2548            FetchSource::Http(parse_remote_url("http://example.invalid/repo.git").expect("url"));
2549        let options = default_options();
2550        let mut credentials = NoCredentials;
2551        let mut progress = SilentProgress;
2552
2553        let outcome = fetch_with_http_client(
2554            FetchRequest {
2555                git_dir: &local,
2556                format: ObjectFormat::Sha1,
2557                config: &GitConfig::default(),
2558                remote_name: "origin",
2559                source: &source,
2560                refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
2561                options: &options,
2562            },
2563            FetchServices {
2564                credentials: &mut credentials,
2565                progress: &mut progress,
2566                ref_hook: None,
2567            },
2568            Some(&client),
2569        )
2570        .expect("fetch via injected client should succeed");
2571
2572        // The injected client owned the dial — a fresh ureq client would instead
2573        // have tried to resolve example.invalid and never touched this recorder.
2574        assert_eq!(
2575            client.get_calls.load(std::sync::atomic::Ordering::Relaxed),
2576            1,
2577            "sley must dial through the injected client"
2578        );
2579        assert_eq!(
2580            client.post_calls.load(std::sync::atomic::Ordering::Relaxed),
2581            0,
2582            "no pack POST expected when the want is already present"
2583        );
2584        // The advertisement still drove a real ref-map update.
2585        assert_eq!(outcome.ref_updates.len(), 1);
2586        let refs = FileRefStore::new(&local, ObjectFormat::Sha1);
2587        assert_eq!(
2588            refs.read_ref("refs/remotes/origin/main")
2589                .expect("ref should read"),
2590            Some(RefTarget::Direct(tip))
2591        );
2592    }
2593
2594    #[test]
2595    fn shallow_local_fetch_writes_depth_boundary_metadata() {
2596        let remote = temp_repo("remote-shallow");
2597        let local = temp_repo("local-shallow");
2598        let tip = commit_on(&remote, "main", "tip");
2599        let source = FetchSource::Local {
2600            git_dir: remote.clone(),
2601            common_git_dir: remote.clone(),
2602        };
2603        let mut options = default_options();
2604        options.depth = Some(1);
2605        let mut credentials = NoCredentials;
2606        let mut progress = SilentProgress;
2607
2608        fetch(
2609            FetchRequest {
2610                git_dir: &local,
2611                format: ObjectFormat::Sha1,
2612                config: &GitConfig::default(),
2613                remote_name: "origin",
2614                source: &source,
2615                refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
2616                options: &options,
2617            },
2618            FetchServices {
2619                credentials: &mut credentials,
2620                progress: &mut progress,
2621                ref_hook: None,
2622            },
2623        )
2624        .expect("shallow fetch should succeed");
2625
2626        assert_eq!(
2627            crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2628                .expect("shallow file should read"),
2629            vec![tip]
2630        );
2631    }
2632
2633    fn pack_file_count(git_dir: &Path) -> usize {
2634        fs::read_dir(git_dir.join("objects/pack"))
2635            .expect("pack directory should read")
2636            .filter_map(|entry| entry.ok())
2637            .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "pack"))
2638            .count()
2639    }
2640
2641    #[test]
2642    fn same_depth_shallow_local_fetch_does_not_install_pack() {
2643        let remote = temp_repo("remote-shallow-noop");
2644        let local = temp_repo("local-shallow-noop");
2645        let tip = commit_on(&remote, "main", "tip");
2646        let source = FetchSource::Local {
2647            git_dir: remote.clone(),
2648            common_git_dir: remote.clone(),
2649        };
2650        let mut options = default_options();
2651        options.depth = Some(1);
2652        let refspecs = ["refs/heads/main:refs/remotes/origin/main".to_string()];
2653        let mut credentials = NoCredentials;
2654        let mut progress = SilentProgress;
2655
2656        fetch(
2657            FetchRequest {
2658                git_dir: &local,
2659                format: ObjectFormat::Sha1,
2660                config: &GitConfig::default(),
2661                remote_name: "origin",
2662                source: &source,
2663                refspecs: &refspecs,
2664                options: &options,
2665            },
2666            FetchServices {
2667                credentials: &mut credentials,
2668                progress: &mut progress,
2669                ref_hook: None,
2670            },
2671        )
2672        .expect("initial shallow fetch should succeed");
2673        let pack_count = pack_file_count(&local);
2674        let shallow = crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2675            .expect("shallow file should read");
2676
2677        fetch(
2678            FetchRequest {
2679                git_dir: &local,
2680                format: ObjectFormat::Sha1,
2681                config: &GitConfig::default(),
2682                remote_name: "origin",
2683                source: &source,
2684                refspecs: &refspecs,
2685                options: &options,
2686            },
2687            FetchServices {
2688                credentials: &mut credentials,
2689                progress: &mut progress,
2690                ref_hook: None,
2691            },
2692        )
2693        .expect("same-depth shallow fetch should succeed");
2694
2695        assert_eq!(pack_file_count(&local), pack_count);
2696        assert_eq!(
2697            crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
2698                .expect("shallow file should read"),
2699            shallow
2700        );
2701        assert_eq!(shallow, vec![tip]);
2702    }
2703
2704    #[test]
2705    fn fetch_head_source_description_redacts_embedded_credentials() {
2706        let config = GitConfig {
2707            sections: vec![ConfigSection::new(
2708                "remote",
2709                Some("origin".into()),
2710                vec![ConfigEntry::new(
2711                    "url",
2712                    Some("https://user:pass@host/repo.git".into()),
2713                )],
2714            )],
2715            ..GitConfig::default()
2716        };
2717        assert_eq!(
2718            fetch_head_source_description(&config, "origin"),
2719            "https://<redacted>@host/repo"
2720        );
2721    }
2722
2723    #[test]
2724    fn failed_local_fetch_does_not_partially_mutate_refs_or_fetch_head() {
2725        let remote = temp_repo("remote-missing");
2726        let local = temp_repo("local-missing");
2727        let old = commit_on(&local, "main", "old local");
2728        let bogus =
2729            ObjectId::from_hex(ObjectFormat::Sha1, &"11".repeat(20)).expect("valid bogus oid");
2730        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2731        let mut tx = remote_refs.transaction();
2732        tx.update(RefUpdate {
2733            name: "refs/heads/main".into(),
2734            expected: None,
2735            new: RefTarget::Direct(bogus),
2736            reflog: None,
2737        });
2738        tx.update(RefUpdate {
2739            name: "HEAD".into(),
2740            expected: None,
2741            new: RefTarget::Symbolic("refs/heads/main".into()),
2742            reflog: None,
2743        });
2744        tx.commit().expect("remote bogus ref should write");
2745        let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
2746        let mut tx = local_refs.transaction();
2747        tx.update(RefUpdate {
2748            name: "refs/remotes/origin/main".into(),
2749            expected: None,
2750            new: RefTarget::Direct(old),
2751            reflog: None,
2752        });
2753        tx.commit().expect("local tracking ref should write");
2754        let source = FetchSource::Local {
2755            git_dir: remote.clone(),
2756            common_git_dir: remote.clone(),
2757        };
2758        let options = default_options();
2759        let mut credentials = NoCredentials;
2760        let mut progress = SilentProgress;
2761
2762        let err = fetch(
2763            FetchRequest {
2764                git_dir: &local,
2765                format: ObjectFormat::Sha1,
2766                config: &GitConfig::default(),
2767                remote_name: "origin",
2768                source: &source,
2769                refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
2770                options: &options,
2771            },
2772            FetchServices {
2773                credentials: &mut credentials,
2774                progress: &mut progress,
2775                ref_hook: None,
2776            },
2777        )
2778        .expect_err("fetch should fail before finalizing refs");
2779
2780        assert!(err.to_string().contains("missing object"));
2781        assert_eq!(
2782            local_refs
2783                .read_ref("refs/remotes/origin/main")
2784                .expect("ref should read"),
2785            Some(RefTarget::Direct(old))
2786        );
2787        assert!(!local.join("FETCH_HEAD").exists());
2788    }
2789
2790    fn config_from_ini(ini: &str) -> GitConfig {
2791        GitConfig::parse(ini.as_bytes()).expect("config should parse")
2792    }
2793
2794    #[test]
2795    fn fetch_max_input_size_unset_means_unlimited() {
2796        assert_eq!(fetch_max_input_size(&GitConfig::default()), None);
2797    }
2798
2799    #[test]
2800    fn fetch_max_input_size_honors_fetch_section() {
2801        let cfg = config_from_ini("[fetch]\n\tmaxInputSize = 64\n");
2802        assert_eq!(fetch_max_input_size(&cfg), Some(64));
2803    }
2804
2805    #[test]
2806    fn fetch_max_input_size_falls_back_to_transfer_max_size() {
2807        let cfg = config_from_ini("[transfer]\n\tmaxSize = 1k\n");
2808        assert_eq!(fetch_max_input_size(&cfg), Some(1024));
2809    }
2810
2811    #[test]
2812    fn fetch_max_input_size_prefers_fetch_over_transfer() {
2813        let cfg = config_from_ini(
2814            "[fetch]\n\tmaxInputSize = 64\n[transfer]\n\tmaxSize = 1k\n",
2815        );
2816        assert_eq!(fetch_max_input_size(&cfg), Some(64));
2817    }
2818
2819    #[test]
2820    fn fetch_max_input_size_non_positive_means_unlimited() {
2821        let cfg = config_from_ini("[fetch]\n\tmaxInputSize = 0\n");
2822        assert_eq!(fetch_max_input_size(&cfg), None);
2823    }
2824}