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::{BTreeSet, HashMap, HashSet};
20use std::fs;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23
24use sley_config::GitConfig;
25use sley_config::remotes::{remote_config_values, remote_exists, rewrite_url_with_config};
26use sley_core::{GitError, ObjectFormat, ObjectId, Result};
27use sley_odb::{
28    FileObjectDatabase, ObjectReader, collect_reachable_object_ids,
29    collect_reachable_object_ids_excluding,
30};
31#[cfg(feature = "http")]
32use sley_protocol::ProtocolVersion;
33use sley_protocol::{
34    FetchHeadRecord, FetchRefUpdate, RefAdvertisement, RefSpec, encode_fetch_head,
35    fetch_ref_updates_to_fetch_head, parse_refspec, plan_fetch_ref_updates, refname_matches,
36    refspec_map_source,
37};
38use sley_refs::{BundleRefUpdate, FileRefStore, Ref, RefTarget};
39use sley_transport::RemoteUrl;
40
41use crate::{CredentialProvider, ProgressSink};
42
43/// How a fetch obtains refs and objects from the remote.
44///
45/// The caller resolves the remote (URL rewriting, repository discovery — all
46/// process-state dependent) and hands `fetch` a concrete transport.
47pub enum FetchSource {
48    /// A smart-HTTP(S) remote at the given already-resolved URL.
49    Http(RemoteUrl),
50    /// An SSH remote at the given already-resolved URL. Fetched by spawning `ssh`
51    /// (the credential seam is unused — the `ssh` program owns authentication).
52    Ssh(RemoteUrl),
53    /// A native anonymous `git://` remote at the given already-resolved URL.
54    Git(RemoteUrl),
55    /// A local repository served in-process from `git_dir`.
56    Local {
57        /// The remote repository's `$GIT_DIR`.
58        git_dir: PathBuf,
59        /// The remote repository's common `$GIT_DIR` (object format source).
60        common_git_dir: PathBuf,
61    },
62}
63
64/// Controls for a [`fetch`] run, mirroring the `git fetch` flags the CLI parses.
65#[derive(Debug, Clone)]
66pub struct FetchOptions {
67    /// Suppress prune notices (deletions still happen; only the [`ProgressSink`]
68    /// output is silenced — the caller wires that).
69    pub quiet: bool,
70    /// Auto-follow annotated tags pointing at fetched commits.
71    pub auto_follow_tags: bool,
72    /// Fetch every tag (`--tags`), independent of reachability.
73    pub fetch_all_tags: bool,
74    /// Prune remote-tracking refs that no longer exist on the remote.
75    pub prune: bool,
76    /// Plan and report the fetch without installing objects or updating refs.
77    pub dry_run: bool,
78    /// Append to `FETCH_HEAD` instead of truncating it.
79    pub append: bool,
80    /// Write `FETCH_HEAD` (the CLI's `--write-fetch-head`).
81    pub write_fetch_head: bool,
82    /// Whether the tag option (`--tags`/`--no-tags`) was set explicitly, so the
83    /// configured `remote.<name>.tagopt` must not override it.
84    pub tag_option_explicit: bool,
85    /// Whether the prune option (`--prune`/`--no-prune`) was set explicitly, so
86    /// the configured `remote.<name>.prune`/`fetch.prune` must not override it.
87    pub prune_option_explicit: bool,
88    /// Shallow fetch depth (`--depth N`): truncate history to `N` commits per tip.
89    /// `None` is a full fetch. Honored by the HTTP and SSH transports and by the
90    /// in-process local (`file://`/path) server, which computes the deepen
91    /// boundary itself (see [`crate::local::compute_local_deepen`]).
92    pub depth: Option<u32>,
93    /// When fetching configured remote refspecs, mark updates whose `src`
94    /// matches one of these (possibly-abbreviated) `branch.<name>.merge` values
95    /// as eligible for merge in `FETCH_HEAD`. More than one entry is an octopus
96    /// merge config. Empty falls back to git's default (first ref of the first
97    /// non-pattern configured refspec). Used by `fetch` (current-branch merge
98    /// config) and `pull`.
99    pub merge_srcs: Vec<String>,
100    /// Partial-clone object filter (`--filter=blob:none`): omit filtered
101    /// objects from the transferred pack. Local-only today: HTTP and SSH do not
102    /// send `filter` requests yet, so callers that require network filtering
103    /// must gate that before calling [`fetch`]. Directly-wanted tips are always
104    /// packed on the local path, mirroring upstream's filter traversal.
105    pub filter: Option<sley_odb::PackObjectFilter>,
106    /// This fetch is a clone (`fetch_pack_args.cloning`): shallow points sent
107    /// by a shallow server are accepted into `$GIT_DIR/shallow` unconditionally.
108    pub cloning: bool,
109    /// `--update-shallow`: accept new shallow points from a shallow server
110    /// (otherwise refs whose history needs them are rejected).
111    pub update_shallow: bool,
112    /// `--deepen=N`: `depth` is relative to the client's current boundary.
113    /// Local-only today; HTTP and SSH treat `depth` as an absolute `--depth N`.
114    pub deepen_relative: bool,
115    /// `--shallow-since=<date>`: deepen to commits newer than the date.
116    /// Local-only today; HTTP and SSH do not send `deepen-since` yet.
117    pub deepen_since: Option<i64>,
118    /// `--shallow-exclude=<ref>`: deepen to commits not reachable from the ref
119    /// (resolved on the remote; a non-ref is an error, like upstream).
120    /// Local-only today; HTTP and SSH do not send `deepen-not` yet.
121    pub deepen_not: Vec<String>,
122}
123
124/// A remote-tracking ref removed by a prune pass.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct PrunedRef {
127    /// The short branch name on the remote (e.g. `topic`).
128    pub branch: String,
129    /// The full local ref name removed (e.g. `refs/remotes/origin/topic`).
130    pub refname: String,
131}
132
133/// The structured result of a [`fetch`].
134#[derive(Debug, Clone, Default)]
135pub struct FetchOutcome {
136    /// The ref updates that were planned (and applied unless `dry_run`), in the
137    /// order they were resolved. Includes auto-followed tags; entries without a
138    /// `dst` are fetch-only (e.g. a bare `HEAD` fetch) and update no local ref.
139    pub ref_updates: Vec<FetchRefUpdate>,
140    /// Remote-tracking refs pruned (empty unless `prune` and the remote is a
141    /// configured remote). Empty on `dry_run`.
142    pub pruned: Vec<PrunedRef>,
143    /// The remote's advertised `HEAD` symref target (e.g. `refs/heads/main`),
144    /// when the remote advertised one. Useful for resolving the default branch.
145    pub head_symref: Option<String>,
146    /// Whether `FETCH_HEAD` was written.
147    pub wrote_fetch_head: bool,
148}
149
150/// Fully resolved inputs for a [`fetch`] run.
151pub struct FetchRequest<'a> {
152    /// Local repository `$GIT_DIR`.
153    pub git_dir: &'a Path,
154    /// Local repository object format.
155    pub format: ObjectFormat,
156    /// Local repository config snapshot.
157    pub config: &'a GitConfig,
158    /// Remote name or source string used for config lookup and `FETCH_HEAD`.
159    pub remote_name: &'a str,
160    /// Already-resolved transport source.
161    pub source: &'a FetchSource,
162    /// Refspecs requested by the caller. Empty means configured fetch refspecs,
163    /// falling back to `HEAD`.
164    pub refspecs: &'a [String],
165    /// Fetch behavior flags.
166    pub options: &'a FetchOptions,
167}
168
169/// Mutable seams used while fetching.
170pub struct FetchServices<'a> {
171    /// Credential source for authenticated transports.
172    pub credentials: &'a mut dyn CredentialProvider,
173    /// Progress sink for prune notices.
174    pub progress: &'a mut dyn ProgressSink,
175}
176
177/// Fetch from a resolved `source` into the repository at `git_dir`.
178///
179/// Performs the work the CLI's `fetch_http_repository`/`fetch_local_repository`
180/// did: applies configured tag/prune options, plans the ref-map for `refspecs`
181/// (empty means the remote's configured fetch refspecs, falling back to `HEAD`),
182/// installs the pack, writes `FETCH_HEAD`, applies remote-tracking updates, and
183/// prunes. `remote_name` is the remote/argument the caller resolved `source`
184/// from (used for `FETCH_HEAD` descriptions and to look up `remote.<name>.*`).
185///
186/// Emits prune notices through `progress` and returns the structured
187/// [`FetchOutcome`]; never prints or returns `GitError::Exit`.
188pub fn fetch(request: FetchRequest<'_>, services: FetchServices<'_>) -> Result<FetchOutcome> {
189    let mut options = request.options.clone();
190    apply_configured_remote_tag_option(request.config, request.remote_name, &mut options);
191    apply_configured_fetch_prune_option(request.config, request.remote_name, &mut options);
192    let promisor_remote = request
193        .config
194        .get_bool("remote", Some(request.remote_name), "promisor")
195        .unwrap_or(false);
196    let configured_refspecs = if request.refspecs.is_empty() {
197        remote_config_values(request.config, request.remote_name, "fetch")
198    } else {
199        Vec::new()
200    };
201    let configured_refspecs_empty = configured_refspecs.is_empty();
202    // git's `get_ref_map`: a default fetch (no command-line refspecs) of the
203    // current branch's tracking remote also fetches the branch's
204    // `branch.<x>.merge` refs (`add_merge_config`) as source-only refs recorded
205    // for-merge in FETCH_HEAD. When the remote has no configured fetch refspec
206    // either, those merge refs replace the bare-`HEAD` default fetch entirely.
207    let has_merge_config = request.refspecs.is_empty() && !options.merge_srcs.is_empty();
208    let default_head_fetch =
209        request.refspecs.is_empty() && configured_refspecs_empty && !has_merge_config;
210    let configured_remote_fetch = request.refspecs.is_empty() && !configured_refspecs_empty;
211    let fetch_head_source = fetch_head_source_description(request.config, request.remote_name);
212    let mut effective_refspecs = fetch_refspecs_for_source(
213        configured_refspecs,
214        request.refspecs,
215        options.fetch_all_tags,
216    );
217    if has_merge_config {
218        // Drop the synthetic bare-`HEAD` refspec the helper inserts when nothing
219        // is configured; the merge refs are fetched for-merge instead.
220        if configured_refspecs_empty && request.refspecs.is_empty() {
221            effective_refspecs.retain(|spec| spec != "HEAD");
222        }
223        // Parse the configured refspecs so coverage (pattern-aware) can be tested
224        // against their sources, mirroring `add_merge_config`'s ref-map lookup.
225        let configured_parsed = effective_refspecs
226            .iter()
227            .map(|refspec| parse_refspec(refspec))
228            .collect::<Result<Vec<_>>>()?;
229        for merge_src in &options.merge_srcs {
230            // git fetches a merge ref only when it is not already reachable
231            // through a configured fetch refspec (`add_merge_config`). A glob
232            // refspec like `refs/heads/*` already covers `refs/heads/three`.
233            let covered = configured_parsed.iter().any(|refspec| {
234                refspec
235                    .src
236                    .as_deref()
237                    .is_some_and(|src| refspec_source_covers(refspec, src, merge_src))
238            });
239            if !covered {
240                // Source-only refspec (no `:dst`): fetched and written to
241                // FETCH_HEAD but creating no local ref.
242                effective_refspecs.push(merge_src.clone());
243            }
244        }
245    }
246    let parsed_refspecs = effective_refspecs
247        .iter()
248        .map(|refspec| parse_refspec(refspec))
249        .collect::<Result<Vec<_>>>()?;
250
251    let store = FileRefStore::new(request.git_dir, request.format);
252    let mut outcome = FetchOutcome::default();
253
254    // Advertise refs, plan the ref-map, install the pack, then update refs/prune.
255    // The two transports differ only in how they advertise and how they pull the
256    // pack; the ref-map planning and ref bookkeeping are identical.
257    let advertisements = match request.source {
258        #[cfg(not(feature = "http"))]
259        FetchSource::Http(_) => {
260            return Err(GitError::Unsupported(
261                "HTTP transport is not enabled in this build".into(),
262            ));
263        }
264        #[cfg(feature = "http")]
265        FetchSource::Http(remote) => {
266            let client = crate::http::new_http_client();
267            let discovered = crate::http::http_service_advertisements(
268                &client,
269                remote,
270                request.format,
271                sley_protocol::GitService::UploadPack,
272                services.credentials,
273            )?;
274            let advertisements = discovered.set.refs;
275            let features = advertisements
276                .first()
277                .map(|advertisement| {
278                    sley_protocol::parse_upload_pack_features(&advertisement.capabilities)
279                })
280                .transpose()?
281                .unwrap_or_default();
282            outcome.head_symref = head_symref_from_features(&features.symrefs);
283            let mut updates = plan_and_adjust_updates(FetchPlanInput {
284                advertisements: &advertisements,
285                refspecs: &parsed_refspecs,
286                options: &options,
287                store: &store,
288                reachable: None,
289                local_db: None,
290                deepen_excluded: None,
291                format: request.format,
292                configured_remote_fetch,
293                has_merge_config,
294            })?;
295            let wants = updates.iter().map(|update| update.oid).collect();
296            // Shallow fetch: replay the current boundary as `shallow` lines and ask
297            // the server to deepen to `depth`, then fold the server's shallow-info
298            // back into `$GIT_DIR/shallow`. A `None` depth keeps the full-fetch path.
299            let existing_shallow =
300                shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
301            let pack_request = crate::http::HttpFetchPackRequest {
302                client: &client,
303                git_dir: request.git_dir,
304                format: request.format,
305                remote,
306                wants,
307                shallow: existing_shallow,
308                deepen: options.depth,
309                promisor: promisor_remote,
310            };
311            let shallow_info = if discovered.set.protocol == ProtocolVersion::V2 {
312                let handshake = discovered.handshake.as_ref().ok_or_else(|| {
313                    GitError::InvalidFormat(
314                        "protocol v2 HTTP fetch requires a v2 handshake from service discovery"
315                            .into(),
316                    )
317                })?;
318                crate::http::install_fetch_pack_via_http_protocol_v2_fetch(
319                    pack_request,
320                    handshake,
321                    services.credentials,
322                )?
323            } else {
324                crate::http::install_fetch_pack_via_http_upload_pack(
325                    pack_request,
326                    services.credentials,
327                )?
328            };
329            if !options.dry_run {
330                crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
331            }
332            finalize_fetch(
333                FetchFinalize {
334                    git_dir: request.git_dir,
335                    format: request.format,
336                    store: &store,
337                    options: &options,
338                    fetch_head_source: &fetch_head_source,
339                    default_head_fetch,
340                },
341                &mut updates,
342                &mut outcome,
343            )?;
344            advertisements
345        }
346        FetchSource::Ssh(remote) => {
347            // SSH advertises and pulls the pack by spawning `ssh` (no credential
348            // seam — the `ssh` program authenticates), but the ref-map planning
349            // and ref bookkeeping are the same shared flow as HTTP.
350            let (advertisements, features) =
351                crate::ssh::ssh_upload_pack_advertisements(remote, request.format)?;
352            outcome.head_symref = head_symref_from_features(&features.symrefs);
353            let mut updates = plan_and_adjust_updates(FetchPlanInput {
354                advertisements: &advertisements,
355                refspecs: &parsed_refspecs,
356                options: &options,
357                store: &store,
358                reachable: None,
359                local_db: None,
360                deepen_excluded: None,
361                format: request.format,
362                configured_remote_fetch,
363                has_merge_config,
364            })?;
365            let wants = updates.iter().map(|update| update.oid).collect();
366            // Shallow fetch over SSH mirrors the HTTP path: replay the current
367            // boundary, deepen to `depth`, then apply the server's shallow-info.
368            let existing_shallow =
369                shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
370            let shallow_info = crate::ssh::install_fetch_pack_via_ssh_upload_pack(
371                crate::ssh::SshFetchPackRequest {
372                    git_dir: request.git_dir,
373                    format: request.format,
374                    remote,
375                    features: &features,
376                    wants,
377                    shallow: existing_shallow,
378                    deepen: options.depth,
379                    promisor: promisor_remote,
380                },
381            )?;
382            if !options.dry_run {
383                crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
384            }
385            finalize_fetch(
386                FetchFinalize {
387                    git_dir: request.git_dir,
388                    format: request.format,
389                    store: &store,
390                    options: &options,
391                    fetch_head_source: &fetch_head_source,
392                    default_head_fetch,
393                },
394                &mut updates,
395                &mut outcome,
396            )?;
397            advertisements
398        }
399        FetchSource::Git(remote) => {
400            let (advertisements, features) =
401                crate::git::git_upload_pack_advertisements(remote, request.format)?;
402            outcome.head_symref = head_symref_from_features(&features.symrefs);
403            let mut updates = plan_and_adjust_updates(FetchPlanInput {
404                advertisements: &advertisements,
405                refspecs: &parsed_refspecs,
406                options: &options,
407                store: &store,
408                reachable: None,
409                local_db: None,
410                deepen_excluded: None,
411                format: request.format,
412                configured_remote_fetch,
413                has_merge_config,
414            })?;
415            let wants = updates.iter().map(|update| update.oid).collect();
416            let existing_shallow =
417                shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
418            let shallow_info = crate::git::install_fetch_pack_via_git_upload_pack(
419                crate::git::GitFetchPackRequest {
420                    git_dir: request.git_dir,
421                    format: request.format,
422                    remote,
423                    features: &features,
424                    wants,
425                    shallow: existing_shallow,
426                    deepen: options.depth,
427                    promisor: promisor_remote,
428                },
429            )?;
430            if !options.dry_run {
431                crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
432            }
433            finalize_fetch(
434                FetchFinalize {
435                    git_dir: request.git_dir,
436                    format: request.format,
437                    store: &store,
438                    options: &options,
439                    fetch_head_source: &fetch_head_source,
440                    default_head_fetch,
441                },
442                &mut updates,
443                &mut outcome,
444            )?;
445            advertisements
446        }
447        FetchSource::Local {
448            git_dir: remote_git_dir,
449            common_git_dir: remote_common_git_dir,
450        } => {
451            let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
452            if remote_format != request.format {
453                return Err(GitError::InvalidObjectId(format!(
454                    "remote repository uses {}, local repository uses {}",
455                    remote_format.name(),
456                    request.format.name()
457                )));
458            }
459            let advertisements =
460                crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
461            // The remote's advertised HEAD symref target (e.g. `refs/heads/main`),
462            // used by the CLI to create `refs/remotes/<remote>/HEAD` on a default
463            // fetch — parity with the network transports' `head_symref`.
464            if let Some(RefTarget::Symbolic(target)) =
465                FileRefStore::new(remote_git_dir, request.format).read_ref("HEAD")?
466            {
467                outcome.head_symref = Some(target);
468            }
469            let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, request.format);
470            // Shallow fetch: the in-process upload-pack needs its deepen plan up
471            // front. The boundary walk starts from the primary planned tips
472            // (upload-pack's `want_obj`) — auto-followed tags are this path's
473            // include-tag equivalent and must not deepen the walk, and the tag
474            // auto-follow below must not see history past the boundary. The
475            // primary plan is recomputed inside `plan_and_adjust_updates`; the
476            // planner is a pure function over the same inputs, so both runs
477            // agree. A `None` depth keeps the full-fetch path.
478            // The remote's own boundary: a shallow server reports its graft
479            // points on ANY fetch (upstream `send_shallow_info` runs an
480            // implicit INFINITE_DEPTH deepen when no deepen was requested).
481            let remote_shallow =
482                crate::shallow::read_shallow(remote_common_git_dir, request.format)?;
483            let explicit_deepen = options.depth.is_some()
484                || options.deepen_since.is_some()
485                || !options.deepen_not.is_empty();
486            let implicit_deepen = !explicit_deepen && !remote_shallow.is_empty();
487            // `--shallow-exclude` values must name refs on the remote
488            // (upstream upload-pack `process_deepen_not`).
489            let mut deepen_not_oids = Vec::new();
490            for name in &options.deepen_not {
491                let resolved = advertisements.iter().find(|advertisement| {
492                    advertisement.name == *name
493                        || advertisement.name == format!("refs/tags/{name}")
494                        || advertisement.name == format!("refs/heads/{name}")
495                        || advertisement.name == format!("refs/{name}")
496                });
497                match resolved {
498                    Some(advertisement) => deepen_not_oids.push(advertisement.oid),
499                    None => {
500                        return Err(GitError::Command(format!(
501                            "git upload-pack: deepen-not is not a ref: {name}"
502                        )));
503                    }
504                }
505            }
506            let plan_deepen = |heads: &[ObjectId]| -> Result<Option<LocalDeepenPlan>> {
507                if !explicit_deepen && !implicit_deepen {
508                    return Ok(None);
509                }
510                // Replay the current boundary, like the HTTP and SSH paths.
511                let client_shallow = crate::shallow::read_shallow(request.git_dir, request.format)?;
512                if options.deepen_since.is_some() || !deepen_not_oids.is_empty() {
513                    return Ok(Some(crate::local::compute_local_deepen_by_rev_list(
514                        &remote_db,
515                        request.format,
516                        heads,
517                        client_shallow,
518                        options.deepen_since,
519                        &deepen_not_oids,
520                    )?));
521                }
522                let depth = options.depth.unwrap_or(crate::local::INFINITE_DEPTH);
523                Ok(Some(crate::local::compute_local_deepen(
524                    &remote_db,
525                    request.format,
526                    heads,
527                    client_shallow,
528                    depth,
529                    options.deepen_relative,
530                )?))
531            };
532            let primary_heads = {
533                let primary = plan_fetch_ref_updates(
534                    &advertisements,
535                    &parsed_refspecs,
536                    options.auto_follow_tags,
537                )?;
538                let mut seen = HashSet::new();
539                let mut heads = Vec::new();
540                for update in &primary {
541                    if seen.insert(update.oid) {
542                        heads.push(update.oid);
543                    }
544                }
545                heads
546            };
547            let mut deepen_plan = plan_deepen(&primary_heads)?;
548            let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
549            let mut updates = plan_and_adjust_updates(FetchPlanInput {
550                advertisements: &advertisements,
551                refspecs: &parsed_refspecs,
552                options: &options,
553                store: &store,
554                reachable: Some((&remote_db, &advertisements)),
555                local_db: Some(&local_db),
556                deepen_excluded: deepen_plan.as_ref().map(|plan| &plan.excluded),
557                format: request.format,
558                configured_remote_fetch,
559                has_merge_config,
560            })?;
561            // A shallow server's new boundary points are only written on a
562            // clone, an explicit deepen, or `--update-shallow`; otherwise the
563            // refs whose history would need them are rejected and dropped
564            // (upstream fetch-pack `update_shallow` + REF_STATUS_REJECT_SHALLOW).
565            if implicit_deepen && !options.cloning && !options.update_shallow {
566                let client_shallow: HashSet<ObjectId> =
567                    crate::shallow::read_shallow(request.git_dir, request.format)?
568                        .into_iter()
569                        .collect();
570                let new_points: HashSet<ObjectId> = deepen_plan
571                    .as_ref()
572                    .map(|plan| {
573                        plan.shallow_info
574                            .iter()
575                            .filter_map(|entry| match entry {
576                                sley_protocol::ProtocolV2FetchShallowInfo::Shallow(oid)
577                                    if !client_shallow.contains(oid) =>
578                                {
579                                    Some(*oid)
580                                }
581                                _ => None,
582                            })
583                            .collect()
584                    })
585                    .unwrap_or_default();
586                if !new_points.is_empty() {
587                    let mut dirty_cache: HashMap<ObjectId, bool> = HashMap::new();
588                    let mut dirty = |tip: &ObjectId| -> Result<bool> {
589                        if let Some(&cached) = dirty_cache.get(tip) {
590                            return Ok(cached);
591                        }
592                        let result =
593                            tip_reaches_boundary(&remote_db, request.format, tip, &new_points)?;
594                        dirty_cache.insert(*tip, result);
595                        Ok(result)
596                    };
597                    let mut kept = Vec::new();
598                    for update in updates {
599                        if dirty(&update.oid)? {
600                            continue;
601                        }
602                        kept.push(update);
603                    }
604                    updates = kept;
605                    // Re-plan the boundary from the surviving tips so the pack
606                    // walk and the shallow-info reflect only what is sent.
607                    let mut seen = HashSet::new();
608                    let mut heads = Vec::new();
609                    for update in &updates {
610                        if seen.insert(update.oid) {
611                            heads.push(update.oid);
612                        }
613                    }
614                    deepen_plan = if heads.is_empty() {
615                        None
616                    } else {
617                        plan_deepen(&heads)?
618                    };
619                }
620            }
621            let starts: Vec<ObjectId> = updates.iter().map(|update| update.oid).collect();
622            let shallow_info = if starts.is_empty() && deepen_plan.is_none() {
623                Vec::new()
624            } else {
625                crate::local::install_fetch_pack_via_local_upload_pack(
626                    request.git_dir,
627                    remote_git_dir,
628                    request.format,
629                    starts,
630                    deepen_plan.as_ref(),
631                    promisor_remote,
632                    options.filter,
633                    None,
634                )?
635            };
636            if !options.dry_run {
637                crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
638            }
639            finalize_fetch(
640                FetchFinalize {
641                    git_dir: request.git_dir,
642                    format: request.format,
643                    store: &store,
644                    options: &options,
645                    fetch_head_source: &fetch_head_source,
646                    default_head_fetch,
647                },
648                &mut updates,
649                &mut outcome,
650            )?;
651            advertisements
652        }
653    };
654
655    if !options.dry_run && options.prune && remote_exists(request.config, request.remote_name) {
656        outcome.pruned = prune_remote_tracking_refs_from_advertisements(
657            request.config,
658            &store,
659            request.remote_name,
660            &advertisements,
661            options.quiet,
662            services.progress,
663        )?;
664    }
665
666    Ok(outcome)
667}
668
669/// Does the (graft-aware) history of `tip` on the remote touch one of the
670/// server's new shallow boundary points? Mirrors upstream
671/// `assign_shallow_commits_to_refs`'s per-ref reachability test.
672fn tip_reaches_boundary<R: sley_odb::ObjectReader>(
673    remote_db: &R,
674    format: ObjectFormat,
675    tip: &ObjectId,
676    boundary: &HashSet<ObjectId>,
677) -> Result<bool> {
678    let mut seen: HashSet<ObjectId> = HashSet::new();
679    let mut queue: Vec<ObjectId> = vec![*tip];
680    while let Some(oid) = queue.pop() {
681        if !seen.insert(oid) {
682            continue;
683        }
684        let object = remote_db.read_object(&oid)?;
685        let commit = match object.object_type {
686            sley_object::ObjectType::Commit => {
687                sley_object::Commit::parse_ref(format, &object.body)?
688            }
689            sley_object::ObjectType::Tag => {
690                let tag = sley_object::Tag::parse_ref(format, &object.body)?;
691                queue.push(tag.object);
692                continue;
693            }
694            _ => continue,
695        };
696        if boundary.contains(&oid) {
697            return Ok(true);
698        }
699        queue.extend(sley_odb::grafted_parents(remote_db, &oid, commit.parents));
700    }
701    Ok(false)
702}
703
704/// The shallow boundary to replay in a deepen request: the oids in
705/// `$GIT_DIR/shallow` when `depth` is set, otherwise empty (a full fetch sends no
706/// `shallow` lines). Reading the file only when deepening keeps the non-shallow
707/// path's wire form unchanged.
708fn shallow_boundary_for_request(
709    git_dir: &Path,
710    format: ObjectFormat,
711    depth: Option<u32>,
712) -> Result<Vec<ObjectId>> {
713    if depth.is_none() {
714        return Ok(Vec::new());
715    }
716    crate::shallow::read_shallow(git_dir, format)
717}
718
719/// Plan the ref-map and apply the auto-follow-tag / not-for-merge adjustments
720/// shared by both transports. `reachable` (local only) enables appending tags
721/// reachable from fetched commits via the remote object database;
722/// `deepen_excluded` (local shallow fetch only) keeps that reachability walk
723/// from crossing the deepen boundary.
724struct FetchPlanInput<'a> {
725    advertisements: &'a [RefAdvertisement],
726    refspecs: &'a [RefSpec],
727    options: &'a FetchOptions,
728    store: &'a FileRefStore,
729    reachable: Option<(&'a FileObjectDatabase, &'a [RefAdvertisement])>,
730    /// The local repository's object database, used to follow tags whose target
731    /// is already present locally (git's `find_non_local_tags` `odb_has_object`
732    /// check). Only the local transport supplies it; auto-follow is local-only.
733    local_db: Option<&'a FileObjectDatabase>,
734    deepen_excluded: Option<&'a HashSet<ObjectId>>,
735    format: ObjectFormat,
736    configured_remote_fetch: bool,
737    /// Default fetch (no command-line refspecs) of the current branch's tracking
738    /// remote with `branch.<x>.merge` configured. The merge refs drive which
739    /// FETCH_HEAD entries are for-merge (`add_merge_config`).
740    has_merge_config: bool,
741}
742
743fn plan_and_adjust_updates(input: FetchPlanInput<'_>) -> Result<Vec<FetchRefUpdate>> {
744    let FetchPlanInput {
745        advertisements,
746        refspecs,
747        options,
748        store,
749        reachable,
750        local_db,
751        deepen_excluded,
752        format,
753        configured_remote_fetch,
754        has_merge_config,
755    } = input;
756    let mut updates = plan_fetch_ref_updates(advertisements, refspecs, options.auto_follow_tags)?;
757    if options.fetch_all_tags {
758        mark_tag_refspec_updates_not_for_merge(&mut updates);
759    } else {
760        if options.auto_follow_tags
761            && let Some((remote_db, advertisements)) = reachable
762        {
763            append_reachable_auto_follow_tags(
764                advertisements,
765                remote_db,
766                local_db,
767                format,
768                refspecs,
769                &mut updates,
770                deepen_excluded,
771            )?;
772        }
773        retain_missing_auto_follow_tags(store, &mut updates)?;
774    }
775    if configured_remote_fetch || has_merge_config {
776        for update in &mut updates {
777            update.not_for_merge = true;
778        }
779        if !options.merge_srcs.is_empty() {
780            // The current branch's `branch.<name>.merge` ref(s) are what we'll
781            // merge, so they are the for-merge entries in FETCH_HEAD. Each entry
782            // is matched with git's abbreviation rules (`branch_merge_matches`);
783            // more than one is an octopus merge config.
784            for update in &mut updates {
785                if options
786                    .merge_srcs
787                    .iter()
788                    .any(|src| refname_matches(src, &update.src))
789                {
790                    update.not_for_merge = false;
791                }
792            }
793        } else if let Some(first) = refspecs.iter().find(|refspec| !refspec.negative)
794            && !first.pattern
795        {
796            // No merge config: mirror git's get_ref_map default, which marks the
797            // first matched ref of the first configured (non-pattern) fetch
798            // refspec as for-merge. Pattern-led configs (e.g. refs/heads/*) leave
799            // every entry not-for-merge.
800            if let Some(update) = updates.first_mut() {
801                update.not_for_merge = false;
802            }
803        }
804        // git's store_updated_refs writes FETCH_HEAD in two passes: all for-merge
805        // entries first (in ref-map order), then all not-for-merge. Reorder
806        // stably to reproduce that layout.
807        updates.sort_by_key(|update| update.not_for_merge);
808    }
809    Ok(updates)
810}
811
812/// Write `FETCH_HEAD`, apply the remote-tracking ref updates, and record the
813/// applied updates in `outcome`. A no-op on `dry_run` (the pack is already
814/// installed; refs and `FETCH_HEAD` are left untouched), matching the CLI.
815struct FetchFinalize<'a> {
816    git_dir: &'a Path,
817    format: ObjectFormat,
818    store: &'a FileRefStore,
819    options: &'a FetchOptions,
820    fetch_head_source: &'a str,
821    default_head_fetch: bool,
822}
823
824/// git's `store_updated_refs` (builtin/fetch.c) downgrades any for-merge
825/// FETCH_HEAD entry whose object does not peel to a commit to not-for-merge: an
826/// explicit `tag <name>` whose tag points at a tree or blob (e.g. `tag-one-tree`)
827/// is recorded but never eligible for merge. Runs after the pack is installed so
828/// the objects are present locally.
829fn downgrade_non_commit_for_merge(
830    git_dir: &Path,
831    format: ObjectFormat,
832    updates: &mut [FetchRefUpdate],
833) {
834    if updates.iter().all(|update| update.not_for_merge) {
835        return;
836    }
837    let db = FileObjectDatabase::from_git_dir(git_dir, format);
838    for update in updates.iter_mut() {
839        if !update.not_for_merge && sley_rev::peel_to_commit(&db, format, &update.oid).is_err() {
840            update.not_for_merge = true;
841        }
842    }
843}
844
845fn finalize_fetch(
846    finalize: FetchFinalize<'_>,
847    updates: &mut Vec<FetchRefUpdate>,
848    outcome: &mut FetchOutcome,
849) -> Result<()> {
850    let FetchFinalize {
851        git_dir,
852        format,
853        store,
854        options,
855        fetch_head_source,
856        default_head_fetch,
857    } = finalize;
858    if options.dry_run {
859        outcome.ref_updates = std::mem::take(updates);
860        return Ok(());
861    }
862    downgrade_non_commit_for_merge(git_dir, format, updates);
863    if options.write_fetch_head {
864        if default_head_fetch
865            && updates.len() == 1
866            && updates[0].src == "HEAD"
867            && updates[0].dst.is_none()
868        {
869            write_default_fetch_head(git_dir, fetch_head_source, updates[0].oid, options.append)?;
870        } else {
871            write_fetch_head(git_dir, fetch_head_source, updates, options.append)?;
872        }
873        outcome.wrote_fetch_head = true;
874    }
875    let ref_updates = updates
876        .iter()
877        .filter_map(|update| {
878            update.dst.as_ref().map(|dst| BundleRefUpdate {
879                name: dst.clone(),
880                oid: update.oid,
881            })
882        })
883        .collect::<Vec<_>>();
884    store.apply_bundle_ref_updates(&ref_updates, None)?;
885    outcome.ref_updates = std::mem::take(updates);
886    Ok(())
887}
888
889/// The remote's advertised `HEAD` symref target (`HEAD:<target>` capability).
890fn head_symref_from_features(symrefs: &[String]) -> Option<String> {
891    symrefs
892        .iter()
893        .find_map(|entry| entry.strip_prefix("HEAD:").map(|target| target.to_string()))
894}
895
896/// Apply the configured `remote.<name>.tagopt` unless the tag option was set
897/// explicitly on the command line.
898pub fn apply_configured_remote_tag_option(
899    config: &GitConfig,
900    source: &str,
901    options: &mut FetchOptions,
902) {
903    if options.tag_option_explicit || !remote_exists(config, source) {
904        return;
905    }
906    match remote_config_values(config, source, "tagopt")
907        .into_iter()
908        .last()
909        .as_deref()
910    {
911        Some("--tags") => {
912            options.auto_follow_tags = true;
913            options.fetch_all_tags = true;
914        }
915        Some("--no-tags") => {
916            options.auto_follow_tags = false;
917            options.fetch_all_tags = false;
918        }
919        _ => {}
920    }
921}
922
923/// Apply the configured `remote.<name>.prune` (then `fetch.prune`) unless the
924/// prune option was set explicitly on the command line.
925pub fn apply_configured_fetch_prune_option(
926    config: &GitConfig,
927    source: &str,
928    options: &mut FetchOptions,
929) {
930    if options.prune_option_explicit || !remote_exists(config, source) {
931        return;
932    }
933    if let Some(prune) = config.get_bool("remote", Some(source), "prune") {
934        options.prune = prune;
935    } else if let Some(prune) = config.get_bool("fetch", None, "prune") {
936        options.prune = prune;
937    }
938}
939
940/// The effective refspec list for a fetch: explicit `refspecs`, else the
941/// `configured` remote refspecs, else `HEAD`; with `refs/tags/*` appended when
942/// fetching all tags.
943pub fn fetch_refspecs_for_source(
944    configured: Vec<String>,
945    refspecs: &[String],
946    fetch_all_tags: bool,
947) -> Vec<String> {
948    let mut effective = if !refspecs.is_empty() {
949        refspecs.to_vec()
950    } else if configured.is_empty() {
951        vec!["HEAD".to_string()]
952    } else {
953        configured
954    };
955    if fetch_all_tags {
956        effective.push("refs/tags/*:refs/tags/*".to_string());
957    }
958    effective
959}
960
961/// Whether a refspec (with source `src`) already covers `merge_src` — the test
962/// `add_merge_config` makes before fetching a `branch.<x>.merge` ref separately.
963/// A pattern source (`refs/heads/*`) covers any ref whose name fits the
964/// prefix/suffix; a literal source matches by git's abbreviated `refname_match`.
965fn refspec_source_covers(refspec: &RefSpec, src: &str, merge_src: &str) -> bool {
966    if refspec.pattern {
967        let Some((prefix, suffix)) = src.split_once('*') else {
968            return false;
969        };
970        // A `branch.<x>.merge` value may be abbreviated (`two` for
971        // `refs/heads/two`); git's `refname_match` resolves it against the
972        // ref-map entry the glob produced. Test the merge ref both verbatim and
973        // qualified under `refs/heads/`, the namespace branch merges live in.
974        let fits = |name: &str| {
975            name.len() >= prefix.len() + suffix.len()
976                && name.starts_with(prefix)
977                && name.ends_with(suffix)
978        };
979        fits(merge_src) || fits(&format!("refs/heads/{merge_src}"))
980    } else {
981        refname_matches(merge_src, src) || refname_matches(src, merge_src)
982    }
983}
984
985/// Mark tag refspec updates (`refs/tags/X:refs/tags/X`) as not-for-merge.
986pub fn mark_tag_refspec_updates_not_for_merge(updates: &mut [FetchRefUpdate]) {
987    for update in updates {
988        if update.src.starts_with("refs/tags/") && update.dst.as_deref() == Some(&update.src) {
989            update.not_for_merge = true;
990        }
991    }
992}
993
994/// Drop auto-followed tags that already exist locally, keeping only missing ones.
995pub fn retain_missing_auto_follow_tags(
996    store: &FileRefStore,
997    updates: &mut Vec<FetchRefUpdate>,
998) -> Result<()> {
999    let mut retained = Vec::with_capacity(updates.len());
1000    for update in updates.drain(..) {
1001        if update.not_for_merge
1002            && update.src.starts_with("refs/tags/")
1003            && update.dst.as_deref() == Some(&update.src)
1004            && store.read_ref(&update.src)?.is_some()
1005        {
1006            continue;
1007        }
1008        retained.push(update);
1009    }
1010    *updates = retained;
1011    Ok(())
1012}
1013
1014/// Append tags reachable from the fetched (non-tag) commits, using the remote
1015/// object database to test reachability.
1016pub fn append_reachable_auto_follow_tags(
1017    advertisements: &[RefAdvertisement],
1018    remote_db: &FileObjectDatabase,
1019    local_db: Option<&FileObjectDatabase>,
1020    format: ObjectFormat,
1021    refspecs: &[RefSpec],
1022    updates: &mut Vec<FetchRefUpdate>,
1023    deepen_excluded: Option<&HashSet<ObjectId>>,
1024) -> Result<()> {
1025    if !updates.iter().any(|update| update.dst.is_some()) {
1026        return Ok(());
1027    }
1028    // Drop any auto-follow tag entries the shared planner added: when we have the
1029    // remote object database we are the authoritative tag follower (we peel
1030    // annotated tags) and we re-add the full set sorted by refname, mirroring
1031    // git's `find_non_local_tags`, which inserts into a sorted string-list.
1032    updates.retain(|update| {
1033        !(update.src.starts_with("refs/tags/")
1034            && update.dst.as_deref() == Some(update.src.as_str())
1035            && update.not_for_merge)
1036    });
1037    // Reachability seeds are every object we're fetching (git's `fetch_oids`):
1038    // non-tag tips directly, and tag updates by their peeled target so an
1039    // explicitly-requested `tag <name>` still seeds the auto-follow of its
1040    // siblings.
1041    let mut starts = Vec::new();
1042    for update in updates.iter().filter(|update| update.dst.is_some()) {
1043        if update.src.starts_with("refs/tags/") {
1044            if let Some(target) = peel_tag_target(remote_db, format, &update.oid)? {
1045                starts.push(target);
1046            } else {
1047                starts.push(update.oid);
1048            }
1049        } else {
1050            starts.push(update.oid);
1051        }
1052    }
1053    // A deepen fetch must not auto-follow tags past the shallow boundary: only
1054    // tags whose target lands in the truncated pack are followed (upstream's
1055    // include-tag packs a tag only when its referenced object is packed).
1056    let reachable = match deepen_excluded {
1057        Some(excluded) => {
1058            collect_reachable_object_ids_excluding(remote_db, format, starts, excluded)?
1059        }
1060        None => collect_reachable_object_ids(remote_db, format, starts)?,
1061    };
1062    let fetched_srcs = updates
1063        .iter()
1064        .map(|update| update.src.clone())
1065        .collect::<HashSet<_>>();
1066    let mut followed = Vec::new();
1067    for reference in advertisements {
1068        if !reference.name.starts_with("refs/tags/")
1069            || fetched_srcs.contains(&reference.name)
1070            || fetch_refspec_excludes(refspecs, &reference.name)?
1071        {
1072            continue;
1073        }
1074        // A tag is auto-followed when the object it ultimately points at is
1075        // either among the objects being fetched (reachable from a fetched tip)
1076        // or already present in the local object database (git's
1077        // `find_non_local_tags`: `oidset_contains(fetch_oids) || odb_has_object`).
1078        // For lightweight tags the target is the advertised oid; for annotated
1079        // tags it is the peeled target (the tag object is never reachable from a
1080        // commit, so peel through the chain).
1081        let target = peel_tag_target(remote_db, format, &reference.oid)?.unwrap_or(reference.oid);
1082        let fetched = reachable.contains(&reference.oid) || reachable.contains(&target);
1083        let present_locally = local_db
1084            .map(|db| db.contains(&target))
1085            .transpose()?
1086            .unwrap_or(false);
1087        if !fetched && !present_locally {
1088            continue;
1089        }
1090        followed.push(FetchRefUpdate {
1091            src: reference.name.clone(),
1092            dst: Some(reference.name.clone()),
1093            oid: reference.oid,
1094            not_for_merge: true,
1095        });
1096    }
1097    followed.sort_by(|a, b| a.src.cmp(&b.src));
1098    updates.extend(followed);
1099    Ok(())
1100}
1101
1102/// Peel an annotated-tag object to the non-tag object it ultimately references,
1103/// following nested tag chains. Returns `None` if `oid` is not an annotated tag
1104/// (a lightweight tag points directly at its target, already the advertised oid)
1105/// or cannot be read from `db`.
1106fn peel_tag_target(
1107    db: &FileObjectDatabase,
1108    format: ObjectFormat,
1109    oid: &ObjectId,
1110) -> Result<Option<ObjectId>> {
1111    let mut current = *oid;
1112    let mut peeled = None;
1113    loop {
1114        let Ok(object) = db.read_object(&current) else {
1115            return Ok(peeled);
1116        };
1117        if object.object_type != sley_object::ObjectType::Tag {
1118            return Ok(peeled);
1119        }
1120        let tag = sley_object::Tag::parse(format, &object.body)?;
1121        current = tag.object;
1122        peeled = Some(current);
1123    }
1124}
1125
1126/// Whether any negative refspec excludes `name`.
1127pub fn fetch_refspec_excludes(refspecs: &[RefSpec], name: &str) -> Result<bool> {
1128    for refspec in refspecs.iter().filter(|refspec| refspec.negative) {
1129        if refspec.pattern {
1130            if refspec_map_source(refspec, name)?.is_some() {
1131                return Ok(true);
1132            }
1133        } else if refspec.src.as_deref() == Some(name) {
1134            return Ok(true);
1135        }
1136    }
1137    Ok(false)
1138}
1139
1140/// Reorder updates so a bundle `--tags` fetch lists non-tags, then tags pointing
1141/// at fetched commits, then the remaining tags (matching git's ordering).
1142pub fn order_bundle_fetch_all_tags_updates(updates: &mut Vec<FetchRefUpdate>) {
1143    let followed_oids = updates
1144        .iter()
1145        .filter(|update| !update.src.starts_with("refs/tags/") && update.dst.is_some())
1146        .map(|update| update.oid)
1147        .collect::<HashSet<_>>();
1148    if followed_oids.is_empty() {
1149        return;
1150    }
1151
1152    let mut non_tags = Vec::new();
1153    let mut followed_tags = Vec::new();
1154    let mut other_tags = Vec::new();
1155    for update in updates.drain(..) {
1156        if update.src.starts_with("refs/tags/") {
1157            if followed_oids.contains(&update.oid) {
1158                followed_tags.push(update);
1159            } else {
1160                other_tags.push(update);
1161            }
1162        } else {
1163            non_tags.push(update);
1164        }
1165    }
1166    updates.extend(non_tags);
1167    updates.extend(followed_tags);
1168    updates.extend(other_tags);
1169}
1170
1171/// Write a single default `FETCH_HEAD` record (a bare `HEAD` fetch).
1172pub fn write_default_fetch_head(
1173    git_dir: &Path,
1174    source: &str,
1175    oid: ObjectId,
1176    append: bool,
1177) -> Result<()> {
1178    let records = [FetchHeadRecord {
1179        oid,
1180        not_for_merge: false,
1181        description: source.to_string(),
1182    }];
1183    write_fetch_head_records(git_dir, &records, append)?;
1184    Ok(())
1185}
1186
1187/// Write `FETCH_HEAD` records, truncating or appending per `append`.
1188pub fn write_fetch_head_records(
1189    git_dir: &Path,
1190    records: &[FetchHeadRecord],
1191    append: bool,
1192) -> Result<()> {
1193    let encoded = encode_fetch_head(records)?;
1194    if append {
1195        let mut file = fs::OpenOptions::new()
1196            .create(true)
1197            .append(true)
1198            .open(git_dir.join("FETCH_HEAD"))?;
1199        file.write_all(&encoded)?;
1200    } else {
1201        fs::write(git_dir.join("FETCH_HEAD"), encoded)?;
1202    }
1203    Ok(())
1204}
1205
1206/// Write `FETCH_HEAD` from fetched ref updates, describing each by `description`.
1207pub fn write_fetch_head(
1208    git_dir: &Path,
1209    description: &str,
1210    fetched: &[FetchRefUpdate],
1211    append: bool,
1212) -> Result<()> {
1213    let records = fetch_ref_updates_to_fetch_head(fetched, description)?;
1214    write_fetch_head_records(git_dir, &records, append)?;
1215    Ok(())
1216}
1217
1218/// The `FETCH_HEAD` source description for `source`: its configured URL (rewritten
1219/// per `url.<base>.insteadOf`) if any, otherwise the rewritten `source`.
1220pub fn fetch_head_source_description(config: &GitConfig, source: &str) -> String {
1221    let url = remote_config_values(config, source, "url")
1222        .into_iter()
1223        .next()
1224        .map(|url| rewrite_url_with_config(config, &url, false))
1225        .unwrap_or_else(|| rewrite_url_with_config(config, source, false));
1226    trim_fetch_head_display_url(&url)
1227}
1228
1229/// Mirror git's `display_state` URL trimming (builtin/fetch.c): strip trailing
1230/// slashes and a trailing `.git` so the `FETCH_HEAD` note reads `branch 'x' of
1231/// ../` rather than `branch 'x' of ../.git/`.
1232fn trim_fetch_head_display_url(url: &str) -> String {
1233    let bytes = url.as_bytes();
1234    let mut end = bytes.len();
1235    while end > 0 && bytes[end - 1] == b'/' {
1236        end -= 1;
1237    }
1238    // `end` is the length excluding trailing slashes; git's `i` (index of the
1239    // last non-slash byte) is `end - 1`, and it strips `.git` only when `i > 4`.
1240    if end > 5 && &bytes[end - 4..end] == b".git" {
1241        end -= 4;
1242    }
1243    String::from_utf8_lossy(&bytes[..end]).into_owned()
1244}
1245
1246/// Prune remote-tracking refs for `remote` that are absent from `advertisements`,
1247/// deleting them and emitting git's notice lines through `progress` (unless
1248/// `quiet`). Returns the refs that were pruned.
1249pub fn prune_remote_tracking_refs_from_advertisements(
1250    config: &GitConfig,
1251    store: &FileRefStore,
1252    remote: &str,
1253    advertisements: &[RefAdvertisement],
1254    quiet: bool,
1255    progress: &mut dyn ProgressSink,
1256) -> Result<Vec<PrunedRef>> {
1257    let remote_branches = advertisements
1258        .iter()
1259        .filter_map(|advertisement| advertisement.name.strip_prefix("refs/heads/"))
1260        .collect::<BTreeSet<_>>();
1261    let local_refs = store.list_refs()?;
1262    let stale_branches = remote_tracking_branch_names(&local_refs, remote)
1263        .into_iter()
1264        .filter(|branch| !remote_branches.contains(branch.as_str()))
1265        .collect::<Vec<_>>();
1266    if stale_branches.is_empty() {
1267        return Ok(Vec::new());
1268    }
1269    let mut emit = |line: &str| {
1270        if !quiet {
1271            progress.message(line);
1272        }
1273    };
1274    let display_url = remote_config_values(config, remote, "url")
1275        .into_iter()
1276        .next()
1277        .unwrap_or_else(|| remote.into());
1278    emit(&format!("Pruning {remote}"));
1279    emit(&format!("URL: {display_url}"));
1280    let remote_head = format!("refs/remotes/{remote}/HEAD");
1281    let remote_prefix = format!("refs/remotes/{remote}/");
1282    let head_target = match store.read_ref(&remote_head)? {
1283        Some(RefTarget::Symbolic(target)) => Some(target),
1284        Some(RefTarget::Direct(_)) | None => None,
1285    };
1286    let mut pruned = Vec::new();
1287    for branch in stale_branches {
1288        let refname = format!("{remote_prefix}{branch}");
1289        match store.read_ref(&refname)? {
1290            Some(RefTarget::Symbolic(_)) => {
1291                let _ = store.delete_symbolic_ref(&refname)?;
1292            }
1293            Some(RefTarget::Direct(_)) => {
1294                let _ = store.delete_ref(&refname)?;
1295            }
1296            None => {}
1297        }
1298        emit(&format!(" * [pruned] {remote}/{branch}"));
1299        if head_target.as_deref() == Some(refname.as_str()) {
1300            let _ = store.delete_symbolic_ref(&remote_head)?;
1301            emit(&format!(
1302                " refs/remotes/{remote}/HEAD has become dangling after {refname} was deleted"
1303            ));
1304        }
1305        pruned.push(PrunedRef { branch, refname });
1306    }
1307    Ok(pruned)
1308}
1309
1310/// Remote-tracking branch names under `refs/remotes/<name>/` (excluding `HEAD`).
1311fn remote_tracking_branch_names(refs: &[Ref], name: &str) -> Vec<String> {
1312    let prefix = format!("refs/remotes/{name}/");
1313    refs.iter()
1314        .filter_map(|reference| reference.name.strip_prefix(&prefix))
1315        .filter(|branch| *branch != "HEAD")
1316        .map(str::to_string)
1317        .collect::<BTreeSet<_>>()
1318        .into_iter()
1319        .collect()
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324    use super::*;
1325    use std::sync::atomic::{AtomicU64, Ordering};
1326
1327    use sley_formats::RepositoryLayout;
1328    use sley_object::{Commit, EncodedObject, ObjectType, Tree};
1329    use sley_odb::{FileObjectDatabase, ObjectWriter};
1330    use sley_refs::{RefTarget, RefUpdate};
1331
1332    use crate::{NoCredentials, SilentProgress};
1333
1334    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1335
1336    fn temp_repo(name: &str) -> PathBuf {
1337        let dir = std::env::temp_dir().join(format!(
1338            "sley-remote-fetch-{name}-{}-{}",
1339            std::process::id(),
1340            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1341        ));
1342        let _ = fs::remove_dir_all(&dir);
1343        RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
1344            .expect("test repository should initialize");
1345        dir.join(".git")
1346    }
1347
1348    fn commit_on(git_dir: &Path, branch: &str, message: &str) -> ObjectId {
1349        let format = ObjectFormat::Sha1;
1350        let db = FileObjectDatabase::from_git_dir(git_dir, format);
1351        let tree = db
1352            .write_object(EncodedObject::new(
1353                ObjectType::Tree,
1354                Tree { entries: vec![] }.write(),
1355            ))
1356            .expect("tree should write");
1357        let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
1358        let oid = db
1359            .write_object(EncodedObject::new(
1360                ObjectType::Commit,
1361                Commit {
1362                    tree,
1363                    parents: Vec::new(),
1364                    author: identity.clone(),
1365                    committer: identity,
1366                    encoding: None,
1367                    message: format!("{message}\n").into_bytes(),
1368                }
1369                .write(),
1370            ))
1371            .expect("commit should write");
1372        let store = FileRefStore::new(git_dir, format);
1373        let mut tx = store.transaction();
1374        tx.update(RefUpdate {
1375            name: format!("refs/heads/{branch}"),
1376            expected: None,
1377            new: RefTarget::Direct(oid),
1378            reflog: None,
1379        });
1380        tx.update(RefUpdate {
1381            name: "HEAD".into(),
1382            expected: None,
1383            new: RefTarget::Symbolic(format!("refs/heads/{branch}")),
1384            reflog: None,
1385        });
1386        tx.commit().expect("refs should update");
1387        oid
1388    }
1389
1390    fn default_options() -> FetchOptions {
1391        FetchOptions {
1392            quiet: true,
1393            auto_follow_tags: false,
1394            fetch_all_tags: false,
1395            prune: false,
1396            dry_run: false,
1397            append: false,
1398            write_fetch_head: true,
1399            tag_option_explicit: true,
1400            prune_option_explicit: true,
1401            depth: None,
1402            merge_srcs: Vec::new(),
1403            filter: None,
1404            cloning: false,
1405            update_shallow: false,
1406            deepen_relative: false,
1407            deepen_since: None,
1408            deepen_not: Vec::new(),
1409        }
1410    }
1411
1412    #[test]
1413    fn local_fetch_installs_pack_updates_ref_and_fetch_head() {
1414        let remote = temp_repo("remote");
1415        let local = temp_repo("local");
1416        let tip = commit_on(&remote, "main", "remote tip");
1417        let source = FetchSource::Local {
1418            git_dir: remote.clone(),
1419            common_git_dir: remote.clone(),
1420        };
1421        let refspecs = vec!["refs/heads/main:refs/remotes/origin/main".to_string()];
1422        let options = default_options();
1423        let mut credentials = NoCredentials;
1424        let mut progress = SilentProgress;
1425
1426        let outcome = fetch(
1427            FetchRequest {
1428                git_dir: &local,
1429                format: ObjectFormat::Sha1,
1430                config: &GitConfig::default(),
1431                remote_name: "origin",
1432                source: &source,
1433                refspecs: &refspecs,
1434                options: &options,
1435            },
1436            FetchServices {
1437                credentials: &mut credentials,
1438                progress: &mut progress,
1439            },
1440        )
1441        .expect("fetch should succeed");
1442
1443        assert_eq!(outcome.ref_updates.len(), 1);
1444        assert!(outcome.wrote_fetch_head);
1445        let local_db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
1446        assert!(local_db.contains(&tip).expect("contains should read"));
1447        let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
1448        assert_eq!(
1449            local_refs
1450                .read_ref("refs/remotes/origin/main")
1451                .expect("ref should read"),
1452            Some(RefTarget::Direct(tip))
1453        );
1454        let fetch_head = fs::read_to_string(local.join("FETCH_HEAD")).expect("FETCH_HEAD exists");
1455        assert!(fetch_head.contains("origin"));
1456    }
1457
1458    #[test]
1459    fn shallow_local_fetch_writes_depth_boundary_metadata() {
1460        let remote = temp_repo("remote-shallow");
1461        let local = temp_repo("local-shallow");
1462        let tip = commit_on(&remote, "main", "tip");
1463        let source = FetchSource::Local {
1464            git_dir: remote.clone(),
1465            common_git_dir: remote.clone(),
1466        };
1467        let mut options = default_options();
1468        options.depth = Some(1);
1469        let mut credentials = NoCredentials;
1470        let mut progress = SilentProgress;
1471
1472        fetch(
1473            FetchRequest {
1474                git_dir: &local,
1475                format: ObjectFormat::Sha1,
1476                config: &GitConfig::default(),
1477                remote_name: "origin",
1478                source: &source,
1479                refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
1480                options: &options,
1481            },
1482            FetchServices {
1483                credentials: &mut credentials,
1484                progress: &mut progress,
1485            },
1486        )
1487        .expect("shallow fetch should succeed");
1488
1489        assert_eq!(
1490            crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
1491                .expect("shallow file should read"),
1492            vec![tip]
1493        );
1494    }
1495
1496    #[test]
1497    fn failed_local_fetch_does_not_partially_mutate_refs_or_fetch_head() {
1498        let remote = temp_repo("remote-missing");
1499        let local = temp_repo("local-missing");
1500        let old = commit_on(&local, "main", "old local");
1501        let bogus =
1502            ObjectId::from_hex(ObjectFormat::Sha1, &"11".repeat(20)).expect("valid bogus oid");
1503        let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1504        let mut tx = remote_refs.transaction();
1505        tx.update(RefUpdate {
1506            name: "refs/heads/main".into(),
1507            expected: None,
1508            new: RefTarget::Direct(bogus),
1509            reflog: None,
1510        });
1511        tx.update(RefUpdate {
1512            name: "HEAD".into(),
1513            expected: None,
1514            new: RefTarget::Symbolic("refs/heads/main".into()),
1515            reflog: None,
1516        });
1517        tx.commit().expect("remote bogus ref should write");
1518        let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
1519        let mut tx = local_refs.transaction();
1520        tx.update(RefUpdate {
1521            name: "refs/remotes/origin/main".into(),
1522            expected: None,
1523            new: RefTarget::Direct(old),
1524            reflog: None,
1525        });
1526        tx.commit().expect("local tracking ref should write");
1527        let source = FetchSource::Local {
1528            git_dir: remote.clone(),
1529            common_git_dir: remote.clone(),
1530        };
1531        let options = default_options();
1532        let mut credentials = NoCredentials;
1533        let mut progress = SilentProgress;
1534
1535        let err = fetch(
1536            FetchRequest {
1537                git_dir: &local,
1538                format: ObjectFormat::Sha1,
1539                config: &GitConfig::default(),
1540                remote_name: "origin",
1541                source: &source,
1542                refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
1543                options: &options,
1544            },
1545            FetchServices {
1546                credentials: &mut credentials,
1547                progress: &mut progress,
1548            },
1549        )
1550        .expect_err("fetch should fail before finalizing refs");
1551
1552        assert!(err.to_string().contains("missing object"));
1553        assert_eq!(
1554            local_refs
1555                .read_ref("refs/remotes/origin/main")
1556                .expect("ref should read"),
1557            Some(RefTarget::Direct(old))
1558        );
1559        assert!(!local.join("FETCH_HEAD").exists());
1560    }
1561}