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