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