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