Skip to main content

sley_remote/
fetch.rs

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