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