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