Skip to main content

grit_lib/
fetch_request.rs

1//! Resolve explicit fetch refspecs (Git `get_ref_map` / `get_remote_ref` semantics).
2//!
3//! Command-line refspecs replace configured [`crate::remote::Remote::fetch_refspecs`] for
4//! stored destinations, while configured refspecs still opportunistically update matching
5//! remote-tracking refs (without duplicating [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion) lines).
6
7use std::collections::{HashMap, HashSet};
8use std::path::Path;
9
10use thiserror::Error;
11
12use crate::config::ConfigSet;
13use crate::objects::ObjectId;
14use crate::refspec::{parse_fetch_refspec, RefspecError, RefspecItem};
15use crate::remote::{Remote, RemoteError, DEFAULT_REMOTE};
16use crate::repo_handles::has_remote_section;
17use crate::transfer::{match_positive, ref_excluded, MatchedRef};
18
19/// How an explicit fetch refspec source side was resolved against advertised refs.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum FetchRefspecSourceKind {
22    /// Source was already a full remote ref name.
23    ExactRemoteRef,
24    /// Expanded via `refs/heads/<short>`.
25    DwimHeads,
26    /// Expanded via `refs/tags/<short>`.
27    DwimTags,
28    /// Expanded via `refs/<short>` (e.g. `pull/123/head`).
29    DwimRefsPrefix,
30    /// Matched the source string literally on the remote.
31    DwimLiteral,
32    /// Empty source (`HEAD` / `@`).
33    Head,
34    /// Source is a 40-character object id.
35    ObjectId,
36}
37
38/// Whether a fetched ref is written to [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion).
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum FetchHeadPolicy {
41    /// Normal command-line refspec: for-merge line.
42    ForMerge,
43    /// Opportunistic remote-tracking update: omit from FETCH_HEAD (Git `FETCH_HEAD_IGNORE`).
44    Ignore,
45}
46
47/// One ref to fetch after resolving explicit refspecs.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ResolvedFetchRef {
50    /// Remote-side ref name (e.g. `refs/heads/main`, `HEAD`, or a hex object id).
51    pub remote_ref: String,
52    /// Object id when the source is `HEAD` or a raw object id (not looked up in advertisements).
53    pub oid: Option<ObjectId>,
54    /// Local ref to update, or `None` when the refspec destination is empty.
55    pub local_ref: Option<String>,
56    /// Whether the refspec carried a `+` force flag.
57    pub force: bool,
58    /// Whether this ref appears in [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion).
59    pub fetch_head: FetchHeadPolicy,
60    /// How the source side was resolved against advertised refs.
61    pub source_kind: FetchRefspecSourceKind,
62}
63
64/// Outcome of [`FetchRefspecPlan::resolve`].
65#[derive(Debug, Clone)]
66pub enum FetchRefspecPlan {
67    /// Wildcard / configured refspecs only: match positive refspec items over all remote refs.
68    Pattern {
69        /// Positive refspec items to match against every advertised ref.
70        positive: Vec<RefspecItem>,
71        /// Negative refspec items excluding advertised refs.
72        negatives: Vec<RefspecItem>,
73    },
74    /// Command-line refspecs include at least one explicit (non-wildcard) source.
75    Mixed {
76        /// Resolved explicit fetch/update pairs in fetch order.
77        explicit: Vec<ResolvedFetchRef>,
78        /// Wildcard / matching refspecs from the same command line.
79        pattern_positive: Vec<RefspecItem>,
80        /// Negative refspec items excluding advertised refs.
81        negatives: Vec<RefspecItem>,
82    },
83}
84
85/// Errors while resolving fetch refspecs before transfer.
86#[derive(Debug, Error, Clone, PartialEq, Eq)]
87pub enum FetchRefspecError {
88    /// A refspec string failed [`parse_fetch_refspec`].
89    #[error("invalid refspec '{spec}': {message}")]
90    InvalidRefspec {
91        /// Original refspec string.
92        spec: String,
93        /// Parse failure detail.
94        message: String,
95    },
96    /// No advertised remote ref matches the source side of an explicit refspec.
97    #[error("remote has no ref matching fetch source '{ref_name}'")]
98    SourceNotFound {
99        /// User-facing source fragment (e.g. `missing-branch`).
100        ref_name: String,
101        /// Original refspec string.
102        spec: String,
103    },
104    /// More than one advertised ref matches an ambiguous short source.
105    #[error("fetch source '{ref_name}' is ambiguous on the remote")]
106    AmbiguousSource {
107        /// User-facing source fragment that matched more than one remote ref.
108        ref_name: String,
109        /// Original refspec string.
110        spec: String,
111    },
112}
113
114impl From<RefspecError> for FetchRefspecError {
115    fn from(e: RefspecError) -> Self {
116        match e {
117            RefspecError::Invalid(s) => Self::InvalidRefspec {
118                spec: s.clone(),
119                message: s,
120            },
121        }
122    }
123}
124
125/// Advertised remote state used while resolving fetch refspecs.
126#[derive(Debug, Clone, Copy, Default)]
127pub struct FetchRemoteAdvertisement<'a> {
128    /// Advertised `(name, oid)` pairs (no `HEAD`, no peeled `^{}`).
129    pub refs: &'a [(String, ObjectId)],
130    /// Tip oid of the remote `HEAD` ref when advertised (even if omitted from `refs`).
131    pub head_oid: Option<ObjectId>,
132}
133
134/// Remote name and explicit refspec strings after parsing `grit fetch` positionals.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct FetchInvocation {
137    /// Configured remote name, URL, or filesystem path to fetch from.
138    pub remote: String,
139    /// Command-line refspecs (`main`, `refs/heads/x:refs/heads/y`, …). Empty uses configured refspecs.
140    pub user_refspecs: Vec<String>,
141}
142
143/// Resolve the repository argument of fetch, pull, or a ref listing into a [`Remote`].
144///
145/// `remote_or_url` is already known to name the repository (it is not a refspec), so a
146/// configured remote name wins, and anything that looks like a URL, an scp-style
147/// `host:path`, or an existing filesystem path becomes an anonymous [`Remote::from_url`].
148///
149/// # Errors
150///
151/// - [`RemoteError::NoUrl`] when the remote has a config section but no `url`.
152/// - [`RemoteError::UnknownRemote`] when `remote_or_url` is neither configured nor URL-like.
153/// - URL parse errors from [`Remote::from_config`] / [`Remote::from_url`].
154pub fn resolve_remote_argument(
155    config: &ConfigSet,
156    remote_or_url: &str,
157) -> Result<Remote, RemoteError> {
158    if has_remote_section(config, remote_or_url) {
159        return Remote::from_config(config, remote_or_url);
160    }
161    if looks_like_path_or_url(remote_or_url) || remote_or_url.contains(':') {
162        return Remote::from_url(remote_or_url);
163    }
164    Err(RemoteError::UnknownRemote(remote_or_url.to_owned()))
165}
166
167/// Former name of [`resolve_remote_argument`].
168///
169/// # Errors
170///
171/// Same as [`resolve_remote_argument`].
172#[deprecated(note = "renamed to resolve_remote_argument")]
173pub fn resolve_fetch_remote(
174    config: &ConfigSet,
175    remote_or_url: &str,
176) -> Result<Remote, RemoteError> {
177    resolve_remote_argument(config, remote_or_url)
178}
179
180/// `branch.<branch>.remote`, trimmed; `None` when unset, blank, or `branch` is `None`.
181fn branch_remote(config: &ConfigSet, branch: Option<&str>) -> Option<String> {
182    branch
183        .and_then(|name| config.get(&format!("branch.{name}.remote")))
184        .map(|remote| remote.trim().to_owned())
185        .filter(|remote| !remote.is_empty())
186}
187
188/// The remote `grit pull` uses when none is named: `branch.<branch>.remote`, else
189/// [`DEFAULT_REMOTE`].
190///
191/// `branch` is the short name of the checked-out branch, or `None` on a detached or
192/// otherwise branchless `HEAD`. A configured value of `.` (the upstream is a local
193/// branch) is returned as is, so pull can integrate it without fetching; empty or
194/// whitespace-only values are ignored. Unlike
195/// [`crate::remote::push_plan::configured_remote_for_push`], `remote.pushDefault` and
196/// `branch.<name>.pushRemote` play no part.
197///
198/// # Returns
199///
200/// The remote name, or `.` for a local upstream.
201#[must_use]
202pub fn configured_remote_for_pull(config: &ConfigSet, branch: Option<&str>) -> String {
203    branch_remote(config, branch).unwrap_or_else(|| DEFAULT_REMOTE.to_owned())
204}
205
206/// The remote `grit fetch` uses when none is named: `branch.<branch>.remote`, else
207/// [`DEFAULT_REMOTE`].
208///
209/// Same as [`configured_remote_for_pull`], except that a local upstream (`.`) also
210/// falls back to [`DEFAULT_REMOTE`]: fetching the repository from itself updates no
211/// remote-tracking ref, so a branch tracking a local branch fetches the default remote.
212///
213/// # Returns
214///
215/// A remote name; never `.`.
216#[must_use]
217pub fn configured_remote_for_fetch(config: &ConfigSet, branch: Option<&str>) -> String {
218    branch_remote(config, branch)
219        .filter(|remote| remote != ".")
220        .unwrap_or_else(|| DEFAULT_REMOTE.to_owned())
221}
222
223/// Classify `grit fetch [<remote>] [<refspec>…]` positionals (no refspec parsing).
224///
225/// When the CLI supplies a first positional (`remote`), it is always the
226/// repository (configured name, URL, scp-style `host:path`, or path) — matching
227/// Git. Remaining positionals are refspecs. With no first positional, an initial
228/// token that looks like a repository is consumed as the remote; otherwise the
229/// remote comes from [`configured_remote_for_fetch`] for `current_branch` and every
230/// token is a refspec.
231///
232/// `current_branch` is the short name of the checked-out branch (`None` when `HEAD`
233/// is detached).
234#[must_use]
235pub fn resolve_fetch_invocation(
236    config: &ConfigSet,
237    remote: Option<&str>,
238    refspecs: &[String],
239    current_branch: Option<&str>,
240) -> FetchInvocation {
241    classify_invocation(config, remote, refspecs, || {
242        configured_remote_for_fetch(config, current_branch)
243    })
244}
245
246/// Classify `grit pull [<remote>] [<refspec>…]` positionals.
247///
248/// Same rules as [`resolve_fetch_invocation`], but the default remote comes from
249/// [`configured_remote_for_pull`], so a branch tracking a local branch yields `.`.
250#[must_use]
251pub fn resolve_pull_invocation(
252    config: &ConfigSet,
253    remote: Option<&str>,
254    refspecs: &[String],
255    current_branch: Option<&str>,
256) -> FetchInvocation {
257    classify_invocation(config, remote, refspecs, || {
258        configured_remote_for_pull(config, current_branch)
259    })
260}
261
262fn classify_invocation(
263    config: &ConfigSet,
264    remote: Option<&str>,
265    refspecs: &[String],
266    default_remote: impl FnOnce() -> String,
267) -> FetchInvocation {
268    if let Some(name) = remote.filter(|s| !s.is_empty()) {
269        return FetchInvocation {
270            remote: name.to_owned(),
271            user_refspecs: refspecs.to_vec(),
272        };
273    }
274    match refspecs.split_first() {
275        Some((first, rest)) if is_fetch_remote_token(config, first) => FetchInvocation {
276            remote: first.clone(),
277            user_refspecs: rest.to_vec(),
278        },
279        _ => FetchInvocation {
280            remote: default_remote(),
281            user_refspecs: refspecs.to_vec(),
282        },
283    }
284}
285
286fn is_fetch_remote_token(config: &ConfigSet, token: &str) -> bool {
287    if has_remote_section(config, token) {
288        return true;
289    }
290    if looks_like_path_or_url(token) {
291        return true;
292    }
293    if token.contains(':') && !token.contains(' ') {
294        return !looks_like_fetch_refspec(token);
295    }
296    false
297}
298
299/// A URL with a scheme, a repository path, or an existing filesystem path.
300///
301/// Colon-only forms (`host:path`) are left to the caller: they are a URL in the remote
302/// position but may be a `src:dst` refspec in a positional list.
303fn looks_like_path_or_url(token: &str) -> bool {
304    token.contains("://")
305        || token.starts_with('/')
306        || token.starts_with("./")
307        || token.starts_with("../")
308        || token.ends_with(".git")
309        || token.ends_with(".bundle")
310        || Path::new(token).exists()
311}
312
313/// True when `token` is an explicit `src:dst` fetch refspec, not an scp-style remote URL.
314fn looks_like_fetch_refspec(token: &str) -> bool {
315    if token.contains('@') {
316        return false;
317    }
318    let Ok(item) = parse_fetch_refspec(token) else {
319        return false;
320    };
321    if item.negative || item.pattern || item.matching {
322        return false;
323    }
324    if !token.contains(':') || item.src.is_none() {
325        return false;
326    }
327    let src = item.src.as_deref().unwrap_or("");
328    let dst = item.dst.as_deref().unwrap_or("");
329    if src.contains('.') && !src.starts_with("refs/") {
330        return false;
331    }
332    if dst.contains('/') && !dst.starts_with("refs/") {
333        return false;
334    }
335    true
336}
337
338/// Inputs for [`FetchRefspecPlan::resolve`].
339#[derive(Debug, Clone, Default)]
340pub struct FetchRefspecInput {
341    /// Refspecs requested for this fetch (command line or caller override).
342    pub user_refspecs: Vec<String>,
343    /// `remote.<name>.fetch` from config (used for opportunistic tracking updates).
344    pub configured_refspecs: Vec<String>,
345    /// Short remote name when fetching a configured remote (`origin`).
346    pub remote_name: Option<String>,
347    /// When true, explicit refspec sources missing from the advertisement are
348    /// kept on the plan so a later prune pass can delete stale tracking refs.
349    pub allow_missing_explicit_for_prune: bool,
350}
351
352fn refspec_item_is_explicit(item: &RefspecItem) -> bool {
353    !item.pattern && !item.matching && !item.negative
354}
355
356impl FetchRefspecPlan {
357    /// Classify refspecs and resolve explicit sources against `remote`.
358    ///
359    /// # Errors
360    ///
361    /// Returns [`FetchRefspecError`] when an explicit source cannot be resolved uniquely.
362    pub fn resolve(
363        input: &FetchRefspecInput,
364        remote: FetchRemoteAdvertisement<'_>,
365    ) -> Result<Self, FetchRefspecError> {
366        let effective = if input.user_refspecs.is_empty() {
367            input.configured_refspecs.clone()
368        } else {
369            input.user_refspecs.clone()
370        };
371
372        let mut negatives = Vec::new();
373        let mut positive_strings = Vec::new();
374        for spec in &effective {
375            let item =
376                parse_fetch_refspec(spec).map_err(|e| FetchRefspecError::InvalidRefspec {
377                    spec: spec.clone(),
378                    message: e.to_string(),
379                })?;
380            if item.negative {
381                negatives.push(item);
382            } else {
383                positive_strings.push(spec.clone());
384            }
385        }
386
387        let has_explicit = positive_strings.iter().any(|s| {
388            parse_fetch_refspec(s)
389                .map(|i| refspec_item_is_explicit(&i))
390                .unwrap_or(false)
391        });
392
393        if !has_explicit {
394            let positive = positive_strings
395                .iter()
396                .map(|s| parse_fetch_refspec(s))
397                .collect::<Result<Vec<_>, _>>()?;
398            return Ok(Self::Pattern {
399                positive,
400                negatives,
401            });
402        }
403
404        let remote_index: HashMap<&str, ObjectId> =
405            remote.refs.iter().map(|(n, o)| (n.as_str(), *o)).collect();
406
407        let mut explicit = Vec::new();
408        let mut pattern_positive = Vec::new();
409
410        for spec in &positive_strings {
411            let item =
412                parse_fetch_refspec(spec).map_err(|e| FetchRefspecError::InvalidRefspec {
413                    spec: spec.clone(),
414                    message: e.to_string(),
415                })?;
416            if item.pattern || item.matching {
417                pattern_positive.push(item);
418                continue;
419            }
420            let src_raw = item.src.as_deref().unwrap_or("");
421            if item.exact_sha1 {
422                if !ObjectId::is_full_hex(src_raw) {
423                    return Err(FetchRefspecError::InvalidRefspec {
424                        spec: spec.clone(),
425                        message: "invalid object id".into(),
426                    });
427                }
428                let oid =
429                    ObjectId::from_hex(src_raw).map_err(|_| FetchRefspecError::InvalidRefspec {
430                        spec: spec.clone(),
431                        message: "invalid object id".into(),
432                    })?;
433                let local_ref = resolve_explicit_destination(item.dst.as_deref(), src_raw);
434                explicit.push(ResolvedFetchRef {
435                    remote_ref: src_raw.to_owned(),
436                    oid: Some(oid),
437                    local_ref,
438                    force: item.force,
439                    fetch_head: FetchHeadPolicy::ForMerge,
440                    source_kind: FetchRefspecSourceKind::ObjectId,
441                });
442                continue;
443            }
444            let (remote_ref, kind, oid) =
445                match resolve_explicit_source(src_raw, &remote_index, remote.head_oid, spec) {
446                    Ok(resolved) => resolved,
447                    Err(FetchRefspecError::SourceNotFound { .. })
448                        if input.allow_missing_explicit_for_prune =>
449                    {
450                        let remote_ref = explicit_remote_ref_for_prune(src_raw);
451                        (remote_ref, FetchRefspecSourceKind::ExactRemoteRef, None)
452                    }
453                    Err(e) => return Err(e),
454                };
455            let local_ref = resolve_explicit_destination(item.dst.as_deref(), &remote_ref);
456            explicit.push(ResolvedFetchRef {
457                remote_ref,
458                oid,
459                local_ref,
460                force: item.force,
461                fetch_head: FetchHeadPolicy::ForMerge,
462                source_kind: kind,
463            });
464        }
465
466        append_opportunistic_tracking(
467            &mut explicit,
468            &input.configured_refspecs,
469            input.remote_name.as_deref(),
470        );
471
472        Ok(Self::Mixed {
473            explicit,
474            pattern_positive,
475            negatives,
476        })
477    }
478
479    /// Whether this fetch used explicit (non-wildcard) command-line refspecs.
480    #[must_use]
481    pub fn is_explicit(&self) -> bool {
482        matches!(self, Self::Mixed { .. })
483    }
484}
485
486/// Build [`MatchedRef`] rows from a resolved plan and advertised remote refs,
487/// using a caller-supplied negative list (for [`FetchOptions::negative_refspecs`](crate::remote::transfer::FetchOptions::negative_refspecs))
488/// and optionally skipping explicit sources missing on the remote when
489/// `prune_missing_explicit` is set (vanished-ref `--prune` fetch).
490pub(crate) fn matched_refs_with_negatives(
491    plan: &FetchRefspecPlan,
492    remote: FetchRemoteAdvertisement<'_>,
493    negatives: &[RefspecItem],
494    prune_missing_explicit: bool,
495) -> Result<Vec<MatchedRef>, FetchRefspecError> {
496    match plan {
497        FetchRefspecPlan::Pattern { positive, .. } => {
498            pattern_matched_refs(remote.refs, positive, negatives)
499        }
500        FetchRefspecPlan::Mixed {
501            explicit,
502            pattern_positive,
503            ..
504        } => {
505            let mut matched = pattern_matched_refs(remote.refs, pattern_positive, negatives)?;
506            let remote_index: HashMap<&str, ObjectId> =
507                remote.refs.iter().map(|(n, o)| (n.as_str(), *o)).collect();
508            for entry in explicit {
509                if ref_excluded(&entry.remote_ref, negatives) {
510                    continue;
511                }
512                let oid = if let Some(o) = entry.oid {
513                    o
514                } else if let Some(o) = remote_index.get(entry.remote_ref.as_str()).copied() {
515                    o
516                } else if prune_missing_explicit {
517                    continue;
518                } else {
519                    return Err(FetchRefspecError::SourceNotFound {
520                        ref_name: entry.remote_ref.clone(),
521                        spec: entry.remote_ref.clone(),
522                    });
523                };
524                matched.push(MatchedRef {
525                    remote_ref: entry.remote_ref.clone(),
526                    local_ref: entry.local_ref.clone(),
527                    oid,
528                    force: entry.force,
529                    is_tag: entry.remote_ref.starts_with("refs/tags/"),
530                    dest_is_tag: ref_update_uses_tag_rules(entry.local_ref.as_deref()),
531                    advertised_peel: None,
532                    fetch_head: entry.fetch_head,
533                });
534            }
535            Ok(matched)
536        }
537    }
538}
539
540fn pattern_matched_refs(
541    remote_refs: &[(String, ObjectId)],
542    positive: &[RefspecItem],
543    negatives: &[RefspecItem],
544) -> Result<Vec<MatchedRef>, FetchRefspecError> {
545    let mut matched = Vec::new();
546    let mut seen = HashSet::new();
547    for (name, oid) in remote_refs {
548        if ref_excluded(name, negatives) {
549            continue;
550        }
551        if let Some(local_ref) = match_positive(name, positive) {
552            let key = (name.clone(), local_ref.clone());
553            if seen.insert(key) {
554                matched.push(MatchedRef {
555                    remote_ref: name.clone(),
556                    local_ref: local_ref.clone(),
557                    oid: *oid,
558                    force: crate::transfer::refspecs_force(name, positive),
559                    is_tag: name.starts_with("refs/tags/"),
560                    dest_is_tag: ref_update_uses_tag_rules(local_ref.as_deref()),
561                    advertised_peel: None,
562                    fetch_head: FetchHeadPolicy::ForMerge,
563                });
564            }
565        }
566    }
567    Ok(matched)
568}
569
570fn append_opportunistic_tracking(
571    entries: &mut Vec<ResolvedFetchRef>,
572    configured: &[String],
573    remote_name: Option<&str>,
574) {
575    if configured.is_empty() {
576        return;
577    }
578    let Some(remote_name) = remote_name else {
579        return;
580    };
581    if remote_name.contains('/') || remote_name.starts_with('.') {
582        return;
583    }
584
585    let mut configured_positive = Vec::new();
586    for spec in configured {
587        if let Ok(item) = parse_fetch_refspec(spec) {
588            if !item.negative {
589                configured_positive.push(item);
590            }
591        }
592    }
593    if configured_positive.is_empty() {
594        return;
595    }
596
597    let primary_remotes: Vec<String> = entries.iter().map(|e| e.remote_ref.clone()).collect();
598    let mut have_local: HashSet<String> =
599        entries.iter().filter_map(|e| e.local_ref.clone()).collect();
600
601    for remote_ref in primary_remotes {
602        if let Some(local) = match_positive(&remote_ref, &configured_positive) {
603            let Some(local) = local else {
604                continue;
605            };
606            if have_local.contains(&local) {
607                continue;
608            }
609            have_local.insert(local.clone());
610            entries.push(ResolvedFetchRef {
611                remote_ref: remote_ref.to_owned(),
612                oid: None,
613                local_ref: Some(local),
614                force: configured_positive.iter().any(|item| {
615                    item.force
616                        && item.src.as_deref().is_some_and(|src| {
617                            crate::transfer::apply_refspec(
618                                src,
619                                item.dst.as_deref(),
620                                remote_ref.as_str(),
621                            )
622                            .is_some()
623                        })
624                }),
625                fetch_head: FetchHeadPolicy::Ignore,
626                source_kind: FetchRefspecSourceKind::ExactRemoteRef,
627            });
628        }
629    }
630}
631
632/// Best-effort remote ref name for an explicit refspec when the source vanished
633/// and `--prune` will delete the stale tracking ref.
634fn explicit_remote_ref_for_prune(src: &str) -> String {
635    if src.is_empty() || src == "HEAD" || src == "@" {
636        return "HEAD".to_owned();
637    }
638    if src.starts_with("refs/") {
639        src.to_owned()
640    } else {
641        format!("refs/heads/{src}")
642    }
643}
644
645/// Explicit refspecs without a `:dst` side (e.g. `fetch origin topic --prune`) do
646/// not carry a tracking destination, so [`crate::transfer::prune_tracking_refs`]
647/// would skip them. When configured refspecs map the vanished source to a
648/// remote-tracking ref, add positive entries with that destination filled in.
649pub(crate) fn supplement_positive_for_explicit_prune(
650    positive: &mut Vec<RefspecItem>,
651    configured_refspecs: &[String],
652    remote_name: Option<&str>,
653) {
654    let Some(remote_name) = remote_name else {
655        return;
656    };
657    if remote_name.contains('/') || remote_name.starts_with('.') {
658        return;
659    }
660    let mut configured_positive = Vec::new();
661    for spec in configured_refspecs {
662        if let Ok(item) = parse_fetch_refspec(spec) {
663            if !item.negative {
664                configured_positive.push(item);
665            }
666        }
667    }
668    if configured_positive.is_empty() {
669        return;
670    }
671
672    let mut extras = Vec::new();
673    for item in positive.iter() {
674        if item.negative || item.pattern || item.matching {
675            continue;
676        }
677        if item.dst.as_ref().is_some_and(|d| !d.is_empty()) {
678            continue;
679        }
680        let Some(src) = item.src.as_deref() else {
681            continue;
682        };
683        let remote_ref = explicit_remote_ref_for_prune(src);
684        let Some(local) =
685            crate::transfer::match_positive(&remote_ref, &configured_positive).and_then(|o| o)
686        else {
687            continue;
688        };
689        extras.push(RefspecItem {
690            dst: Some(local),
691            ..item.clone()
692        });
693    }
694    positive.extend(extras);
695}
696
697fn resolve_explicit_source(
698    src: &str,
699    remote: &HashMap<&str, ObjectId>,
700    head_oid: Option<ObjectId>,
701    spec: &str,
702) -> Result<(String, FetchRefspecSourceKind, Option<ObjectId>), FetchRefspecError> {
703    if src.is_empty() || src == "HEAD" || src == "@" {
704        let Some(oid) = head_oid.filter(|o| !o.is_zero()) else {
705            return Err(FetchRefspecError::SourceNotFound {
706                ref_name: "HEAD".to_owned(),
707                spec: spec.to_owned(),
708            });
709        };
710        return Ok(("HEAD".to_owned(), FetchRefspecSourceKind::Head, Some(oid)));
711    }
712
713    if remote.contains_key(src) {
714        return Ok((src.to_owned(), FetchRefspecSourceKind::ExactRemoteRef, None));
715    }
716
717    let mut candidates: Vec<(u8, String, FetchRefspecSourceKind)> = Vec::new();
718    let try_push =
719        |candidates: &mut Vec<_>, priority: u8, name: String, kind: FetchRefspecSourceKind| {
720            if remote.contains_key(name.as_str()) {
721                candidates.push((priority, name, kind));
722            }
723        };
724
725    try_push(
726        &mut candidates,
727        0,
728        format!("refs/heads/{src}"),
729        FetchRefspecSourceKind::DwimHeads,
730    );
731    try_push(
732        &mut candidates,
733        1,
734        format!("refs/tags/{src}"),
735        FetchRefspecSourceKind::DwimTags,
736    );
737    if src.starts_with("refs/") {
738        try_push(
739            &mut candidates,
740            2,
741            src.to_owned(),
742            FetchRefspecSourceKind::DwimLiteral,
743        );
744    } else {
745        try_push(
746            &mut candidates,
747            2,
748            format!("refs/{src}"),
749            FetchRefspecSourceKind::DwimRefsPrefix,
750        );
751        try_push(
752            &mut candidates,
753            3,
754            src.to_owned(),
755            FetchRefspecSourceKind::DwimLiteral,
756        );
757    }
758
759    candidates.sort_by_key(|(p, _, _)| *p);
760    match candidates.len() {
761        0 => Err(FetchRefspecError::SourceNotFound {
762            ref_name: src.to_owned(),
763            spec: spec.to_owned(),
764        }),
765        1 => Ok((candidates[0].1.clone(), candidates[0].2, None)),
766        _ => {
767            let best = candidates[0].0;
768            let at_best: Vec<_> = candidates.iter().filter(|(p, _, _)| *p == best).collect();
769            if at_best.len() == 1 {
770                Ok((at_best[0].1.clone(), at_best[0].2, None))
771            } else {
772                Err(FetchRefspecError::AmbiguousSource {
773                    ref_name: src.to_owned(),
774                    spec: spec.to_owned(),
775                })
776            }
777        }
778    }
779}
780
781/// Whether ref-update rules treat the destination as a tag (no overwrite without `+`).
782#[must_use]
783pub(crate) fn ref_update_uses_tag_rules(local_ref: Option<&str>) -> bool {
784    local_ref.is_some_and(|r| r.starts_with("refs/tags/"))
785}
786
787fn resolve_explicit_destination(dst: Option<&str>, _remote_ref: &str) -> Option<String> {
788    match dst {
789        None | Some("") => None,
790        Some(d) if d.starts_with("refs/") => Some(d.to_owned()),
791        Some(d)
792            if d.starts_with("heads/") || d.starts_with("tags/") || d.starts_with("remotes/") =>
793        {
794            Some(format!("refs/{d}"))
795        }
796        Some(d) => Some(format!("refs/heads/{d}")),
797    }
798}
799
800/// Description line for one explicit-fetch [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion) entry.
801#[must_use]
802pub(crate) fn fetch_head_description(remote_ref: &str, is_tag: bool, source: &str) -> String {
803    if remote_ref == "HEAD" {
804        return format!("HEAD of {source}");
805    }
806    if is_tag || remote_ref.starts_with("refs/tags/") {
807        let short = remote_ref.strip_prefix("refs/tags/").unwrap_or(remote_ref);
808        return format!("tag '{short}' of {source}");
809    }
810    if let Some(short) = remote_ref.strip_prefix("refs/heads/") {
811        return format!("branch '{short}' of {source}");
812    }
813    format!("'{remote_ref}' of {source}")
814}
815
816/// Score for [`refname_match_fetch_dwim`] (higher = preferred).
817#[must_use]
818pub fn refname_match_fetch_dwim(abbrev: &str, full: &str) -> u32 {
819    if abbrev == full {
820        return 100;
821    }
822    if full == format!("refs/heads/{abbrev}") {
823        return 90;
824    }
825    if full == format!("refs/tags/{abbrev}") {
826        return 80;
827    }
828    if full == format!("refs/{abbrev}") {
829        return 70;
830    }
831    0
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837
838    fn remote_refs(names: &[&str]) -> Vec<(String, ObjectId)> {
839        let oid = ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap();
840        names.iter().map(|n| (n.to_string(), oid)).collect()
841    }
842
843    fn adv<'a>(refs: &'a [(String, ObjectId)]) -> FetchRemoteAdvertisement<'a> {
844        FetchRemoteAdvertisement {
845            refs,
846            head_oid: None,
847        }
848    }
849
850    fn matched_from_plan(
851        plan: &FetchRefspecPlan,
852        remote: FetchRemoteAdvertisement<'_>,
853    ) -> Vec<MatchedRef> {
854        let negatives = match plan {
855            FetchRefspecPlan::Pattern { negatives, .. }
856            | FetchRefspecPlan::Mixed { negatives, .. } => negatives.as_slice(),
857        };
858        matched_refs_with_negatives(plan, remote, negatives, false).unwrap()
859    }
860
861    #[test]
862    fn dwim_prefers_heads_over_tags() {
863        let refs = remote_refs(&["refs/heads/main", "refs/tags/main"]);
864        let (name, kind, _) = resolve_explicit_source(
865            "main",
866            &refs.iter().map(|(n, o)| (n.as_str(), *o)).collect(),
867            None,
868            "main",
869        )
870        .unwrap();
871        assert_eq!(name, "refs/heads/main");
872        assert_eq!(kind, FetchRefspecSourceKind::DwimHeads);
873    }
874
875    #[test]
876    fn pull_request_ref_expands_under_refs() {
877        let refs = remote_refs(&["refs/pull/123/head"]);
878        let (name, kind, _) = resolve_explicit_source(
879            "pull/123/head",
880            &refs.iter().map(|(n, o)| (n.as_str(), *o)).collect(),
881            None,
882            "pull/123/head",
883        )
884        .unwrap();
885        assert_eq!(name, "refs/pull/123/head");
886        assert_eq!(kind, FetchRefspecSourceKind::DwimRefsPrefix);
887    }
888
889    #[test]
890    fn head_uses_advertised_head_oid() {
891        let oid = ObjectId::from_hex("00000000000000000000000000000000000000ab").unwrap();
892        let (name, kind, resolved) =
893            resolve_explicit_source("HEAD", &HashMap::new(), Some(oid), "HEAD").unwrap();
894        assert_eq!(name, "HEAD");
895        assert_eq!(kind, FetchRefspecSourceKind::Head);
896        assert_eq!(resolved, Some(oid));
897    }
898
899    #[test]
900    fn mixed_explicit_and_pattern_refspecs() {
901        let refs = remote_refs(&["refs/heads/main", "refs/heads/extra"]);
902        let plan = FetchRefspecPlan::resolve(
903            &FetchRefspecInput {
904                user_refspecs: vec!["main".into(), "+refs/heads/*:refs/remotes/other/*".into()],
905                configured_refspecs: vec![],
906                remote_name: Some("origin".into()),
907                ..Default::default()
908            },
909            adv(&refs),
910        )
911        .unwrap();
912        let matched = matched_from_plan(&plan, adv(&refs));
913        assert!(
914            matched
915                .iter()
916                .any(|m| m.local_ref.as_deref() == Some("refs/remotes/other/main")),
917            "pattern refspec should map main to other/*"
918        );
919    }
920
921    #[test]
922    fn opportunistic_exact_configured_mapping() {
923        let refs = remote_refs(&["refs/heads/main"]);
924        let plan = FetchRefspecPlan::resolve(
925            &FetchRefspecInput {
926                user_refspecs: vec!["main".into()],
927                configured_refspecs: vec!["refs/heads/main:refs/remotes/origin/main".into()],
928                remote_name: Some("origin".into()),
929                ..Default::default()
930            },
931            adv(&refs),
932        )
933        .unwrap();
934        let matched = matched_from_plan(&plan, adv(&refs));
935        assert!(
936            matched
937                .iter()
938                .any(|m| m.local_ref.as_deref() == Some("refs/remotes/origin/main")),
939            "exact configured mapping should opportunistically update tracking ref"
940        );
941    }
942
943    #[test]
944    fn extra_negative_refspec_excludes_from_wildcard_match() {
945        let refs = remote_refs(&["refs/heads/main", "refs/heads/topic", "refs/heads/side"]);
946        let plan = FetchRefspecPlan::resolve(
947            &FetchRefspecInput {
948                user_refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".into()],
949                ..Default::default()
950            },
951            adv(&refs),
952        )
953        .unwrap();
954        let extra = vec![parse_fetch_refspec("^refs/heads/topic").unwrap()];
955        let matched = matched_refs_with_negatives(&plan, adv(&refs), &extra, false).unwrap();
956        assert!(
957            matched
958                .iter()
959                .any(|m| m.local_ref.as_deref() == Some("refs/remotes/origin/main")),
960            "main must match"
961        );
962        assert!(
963            matched
964                .iter()
965                .any(|m| m.local_ref.as_deref() == Some("refs/remotes/origin/side")),
966            "side must match"
967        );
968        assert!(
969            !matched
970                .iter()
971                .any(|m| m.local_ref.as_deref() == Some("refs/remotes/origin/topic")),
972            "negative refspec must drop topic"
973        );
974    }
975
976    #[test]
977    fn explicit_remote_ref_for_prune_keeps_head_symbol() {
978        assert_eq!(explicit_remote_ref_for_prune("HEAD"), "HEAD");
979        assert_eq!(explicit_remote_ref_for_prune("@"), "HEAD");
980        assert_eq!(explicit_remote_ref_for_prune(""), "HEAD");
981        assert_eq!(explicit_remote_ref_for_prune("topic"), "refs/heads/topic");
982    }
983
984    #[test]
985    fn vanished_head_prune_does_not_match_refs_heads_head_branch() {
986        let oid = ObjectId::from_hex("0000000000000000000000000000000000000002").unwrap();
987        let refs = vec![("refs/heads/HEAD".to_string(), oid)];
988        let plan = FetchRefspecPlan::resolve(
989            &FetchRefspecInput {
990                user_refspecs: vec!["HEAD".into()],
991                allow_missing_explicit_for_prune: true,
992                ..Default::default()
993            },
994            FetchRemoteAdvertisement {
995                refs: &refs,
996                head_oid: None,
997            },
998        )
999        .unwrap();
1000        let matched = matched_refs_with_negatives(
1001            &plan,
1002            FetchRemoteAdvertisement {
1003                refs: &refs,
1004                head_oid: None,
1005            },
1006            &[],
1007            true,
1008        )
1009        .unwrap();
1010        assert!(
1011            matched.is_empty(),
1012            "vanished HEAD must not be confused with a refs/heads/HEAD branch"
1013        );
1014    }
1015
1016    #[test]
1017    fn supplement_positive_adds_tracking_dst_for_short_explicit_prune() {
1018        let mut positive = vec![parse_fetch_refspec("topic").unwrap()];
1019        supplement_positive_for_explicit_prune(
1020            &mut positive,
1021            &["+refs/heads/*:refs/remotes/origin/*".into()],
1022            Some("origin"),
1023        );
1024        assert!(
1025            positive.iter().any(|i| {
1026                i.src.as_deref() == Some("topic")
1027                    && i.dst.as_deref() == Some("refs/remotes/origin/topic")
1028            }),
1029            "short explicit refspec must gain configured tracking dst for prune"
1030        );
1031    }
1032
1033    #[test]
1034    fn prune_skips_missing_explicit_source_instead_of_error() {
1035        let refs = remote_refs(&["refs/heads/main"]);
1036        let plan = FetchRefspecPlan::resolve(
1037            &FetchRefspecInput {
1038                user_refspecs: vec!["+refs/heads/topic:refs/remotes/origin/topic".into()],
1039                configured_refspecs: vec![],
1040                remote_name: None,
1041                allow_missing_explicit_for_prune: true,
1042            },
1043            adv(&refs),
1044        )
1045        .unwrap();
1046        let matched = matched_refs_with_negatives(&plan, adv(&refs), &[], true).unwrap();
1047        assert!(
1048            matched.is_empty(),
1049            "vanished explicit source with prune must not error or match"
1050        );
1051    }
1052
1053    #[test]
1054    fn duplicate_remote_ref_two_destinations() {
1055        let refs = remote_refs(&["refs/heads/main"]);
1056        let plan = FetchRefspecPlan::resolve(
1057            &FetchRefspecInput {
1058                user_refspecs: vec!["main:refs/heads/a".into(), "main:refs/heads/b".into()],
1059                ..Default::default()
1060            },
1061            adv(&refs),
1062        )
1063        .unwrap();
1064        let matched = matched_from_plan(&plan, adv(&refs));
1065        assert_eq!(
1066            matched
1067                .iter()
1068                .filter(|m| m.remote_ref == "refs/heads/main")
1069                .count(),
1070            2
1071        );
1072    }
1073
1074    #[test]
1075    fn object_id_refspec_carries_oid() {
1076        let oid = ObjectId::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
1077        let refs: Vec<(String, ObjectId)> = vec![];
1078        let plan = FetchRefspecPlan::resolve(
1079            &FetchRefspecInput {
1080                user_refspecs: vec![format!("{oid}:refs/heads/imported")],
1081                ..Default::default()
1082            },
1083            adv(&refs),
1084        )
1085        .unwrap();
1086        let matched = matched_from_plan(&plan, adv(&refs));
1087        assert_eq!(matched.len(), 1);
1088        assert_eq!(matched[0].oid, oid);
1089    }
1090
1091    #[test]
1092    fn short_destination_maps_to_refs_heads() {
1093        assert_eq!(
1094            resolve_explicit_destination(Some("pr-123"), "refs/pull/123/head").as_deref(),
1095            Some("refs/heads/pr-123")
1096        );
1097    }
1098
1099    #[test]
1100    fn opportunistic_tracking_adds_origin_main() {
1101        let mut entries = vec![ResolvedFetchRef {
1102            remote_ref: "refs/heads/main".into(),
1103            oid: None,
1104            local_ref: Some("refs/heads/local-main".into()),
1105            force: false,
1106            fetch_head: FetchHeadPolicy::ForMerge,
1107            source_kind: FetchRefspecSourceKind::DwimHeads,
1108        }];
1109        append_opportunistic_tracking(
1110            &mut entries,
1111            &["+refs/heads/*:refs/remotes/origin/*".into()],
1112            Some("origin"),
1113        );
1114        assert!(entries.iter().any(
1115            |e| e.local_ref.as_deref() == Some("refs/remotes/origin/main")
1116                && e.fetch_head == FetchHeadPolicy::Ignore
1117        ));
1118    }
1119
1120    #[test]
1121    fn refname_match_fetch_dwim_ordering() {
1122        assert!(
1123            refname_match_fetch_dwim("main", "refs/heads/main")
1124                > refname_match_fetch_dwim("main", "refs/tags/main")
1125        );
1126    }
1127
1128    #[test]
1129    fn colon_refspec_is_not_treated_as_remote() {
1130        use super::{resolve_fetch_invocation, FetchInvocation};
1131        use crate::config::{ConfigFile, ConfigScope, ConfigSet};
1132
1133        let file = ConfigFile::parse(
1134            std::path::Path::new("cfg"),
1135            "[remote \"origin\"]\n\turl = /tmp/x.git\n",
1136            ConfigScope::Local,
1137        )
1138        .expect("parse");
1139        let mut config = ConfigSet::new();
1140        config.merge(&file);
1141
1142        assert_eq!(
1143            resolve_fetch_invocation(&config, None, &["main:refs/heads/feature".into()], None),
1144            FetchInvocation {
1145                remote: "origin".into(),
1146                user_refspecs: vec!["main:refs/heads/feature".into()],
1147            }
1148        );
1149    }
1150
1151    #[test]
1152    fn explicit_remote_positional_includes_scp_style_urls() {
1153        use super::{resolve_fetch_invocation, FetchInvocation};
1154        use crate::config::ConfigSet;
1155
1156        let config = ConfigSet::new();
1157        assert_eq!(
1158            resolve_fetch_invocation(&config, Some("example.com:repo"), &[], None),
1159            FetchInvocation {
1160                remote: "example.com:repo".into(),
1161                user_refspecs: vec![],
1162            }
1163        );
1164        assert_eq!(
1165            resolve_fetch_invocation(&config, Some("git@host:org/repo"), &[], None),
1166            FetchInvocation {
1167                remote: "git@host:org/repo".into(),
1168                user_refspecs: vec![],
1169            }
1170        );
1171    }
1172
1173    #[test]
1174    fn resolve_remote_argument_prefers_configured_then_url_forms() {
1175        use crate::config::{ConfigFile, ConfigScope, ConfigSet};
1176        use crate::remote::RemoteUrl;
1177
1178        let file = ConfigFile::parse(
1179            std::path::Path::new("cfg"),
1180            "[remote \"origin\"]\n\turl = /tmp/x.git\n[remote \"nourl\"]\n\tfetch = +refs/heads/*:refs/remotes/nourl/*\n",
1181            ConfigScope::Local,
1182        )
1183        .expect("parse");
1184        let mut config = ConfigSet::new();
1185        config.merge(&file);
1186
1187        let origin = resolve_remote_argument(&config, "origin").expect("origin");
1188        assert_eq!(origin.name.as_deref(), Some("origin"));
1189
1190        for url in [
1191            "https://example.com/r.git",
1192            "./sibling",
1193            "../sibling",
1194            "/abs/repo",
1195            "git@host:org/repo",
1196            "host:repo",
1197        ] {
1198            let remote = resolve_remote_argument(&config, url).expect(url);
1199            assert_eq!(remote.name, None, "{url}");
1200        }
1201        assert!(matches!(
1202            resolve_remote_argument(&config, "host:repo").map(|r| r.fetch_url().clone()),
1203            Ok(RemoteUrl::Ssh(_))
1204        ));
1205
1206        assert!(matches!(
1207            resolve_remote_argument(&config, "nourl"),
1208            Err(RemoteError::NoUrl { remote: Some(name) }) if name == "nourl"
1209        ));
1210        let err = resolve_remote_argument(&config, "nosuch").expect_err("unknown");
1211        assert!(matches!(&err, RemoteError::UnknownRemote(name) if name == "nosuch"));
1212        assert_eq!(err.to_string(), "unknown remote 'nosuch'");
1213    }
1214
1215    #[test]
1216    fn url_less_configured_remote_is_consumed_as_the_remote() {
1217        use super::{resolve_fetch_invocation, FetchInvocation};
1218        use crate::config::{ConfigFile, ConfigScope, ConfigSet};
1219
1220        let file = ConfigFile::parse(
1221            std::path::Path::new("cfg"),
1222            "[remote \"half\"]\n\tfetch = +refs/heads/*:refs/remotes/half/*\n",
1223            ConfigScope::Local,
1224        )
1225        .expect("parse");
1226        let mut config = ConfigSet::new();
1227        config.merge(&file);
1228
1229        let invocation =
1230            resolve_fetch_invocation(&config, None, &["half".into(), "main".into()], None);
1231        assert_eq!(
1232            invocation,
1233            FetchInvocation {
1234                remote: "half".into(),
1235                user_refspecs: vec!["main".into()],
1236            }
1237        );
1238        assert!(matches!(
1239            resolve_remote_argument(&config, &invocation.remote),
1240            Err(RemoteError::NoUrl { remote: Some(name) }) if name == "half"
1241        ));
1242    }
1243
1244    #[test]
1245    fn configured_remote_with_colon_refspec() {
1246        use super::{resolve_fetch_invocation, FetchInvocation};
1247        use crate::config::{ConfigFile, ConfigScope, ConfigSet};
1248
1249        let file = ConfigFile::parse(
1250            std::path::Path::new("cfg"),
1251            "[remote \"origin\"]\n\turl = /tmp/x.git\n",
1252            ConfigScope::Local,
1253        )
1254        .expect("parse");
1255        let mut config = ConfigSet::new();
1256        config.merge(&file);
1257
1258        assert_eq!(
1259            resolve_fetch_invocation(&config, Some("origin"), &["a:b".into()], None),
1260            FetchInvocation {
1261                remote: "origin".into(),
1262                user_refspecs: vec!["a:b".into()],
1263            }
1264        );
1265    }
1266
1267    #[test]
1268    fn resolve_fetch_invocation_remote_vs_refspec() {
1269        use super::{resolve_fetch_invocation, FetchInvocation};
1270        use crate::config::{ConfigFile, ConfigScope, ConfigSet};
1271
1272        let file = ConfigFile::parse(
1273            std::path::Path::new("cfg"),
1274            "[remote \"origin\"]\n\turl = /tmp/x.git\n",
1275            ConfigScope::Local,
1276        )
1277        .expect("parse");
1278        let mut config = ConfigSet::new();
1279        config.merge(&file);
1280
1281        assert_eq!(
1282            resolve_fetch_invocation(&config, Some("origin"), &["main".into()], None),
1283            FetchInvocation {
1284                remote: "origin".into(),
1285                user_refspecs: vec!["main".into()],
1286            }
1287        );
1288        assert_eq!(
1289            resolve_fetch_invocation(&config, None, &["main".into()], None),
1290            FetchInvocation {
1291                remote: "origin".into(),
1292                user_refspecs: vec!["main".into()],
1293            }
1294        );
1295
1296        let refspec = "refs/heads/main:refs/remotes/origin/main".to_owned();
1297        assert_eq!(
1298            resolve_fetch_invocation(&config, None, &[refspec.clone()], None),
1299            FetchInvocation {
1300                remote: "origin".into(),
1301                user_refspecs: vec![refspec],
1302            }
1303        );
1304
1305        assert!(super::is_fetch_remote_token(
1306            &config,
1307            "git@github.com:org/repo.git"
1308        ));
1309        assert!(!super::looks_like_fetch_refspec(
1310            "git@github.com:org/repo.git"
1311        ));
1312    }
1313
1314    fn branch_remote_config(body: &str) -> crate::config::ConfigSet {
1315        use crate::config::{ConfigFile, ConfigScope, ConfigSet};
1316
1317        let file = ConfigFile::parse(std::path::Path::new("cfg"), body, ConfigScope::Local)
1318            .expect("parse");
1319        let mut config = ConfigSet::new();
1320        config.merge(&file);
1321        config
1322    }
1323
1324    const BRANCH_REMOTES: &str =
1325        "[remote \"origin\"]\n\turl = /tmp/a.git\n[remote \"backup\"]\n\turl = /tmp/b.git\n\
1326         [branch \"main\"]\n\tremote = backup\n[branch \"local\"]\n\tremote = .\n\
1327         [branch \"blank\"]\n\tremote = \"  \"\n[branch \"padded\"]\n\tremote = \" backup \"\n";
1328
1329    #[test]
1330    fn configured_remote_for_pull_uses_branch_remote_then_origin() {
1331        use super::configured_remote_for_pull;
1332
1333        let config = branch_remote_config(BRANCH_REMOTES);
1334        assert_eq!(configured_remote_for_pull(&config, Some("main")), "backup");
1335        assert_eq!(configured_remote_for_pull(&config, Some("local")), ".");
1336        assert_eq!(configured_remote_for_pull(&config, Some("blank")), "origin");
1337        assert_eq!(
1338            configured_remote_for_pull(&config, Some("padded")),
1339            "backup"
1340        );
1341        assert_eq!(configured_remote_for_pull(&config, Some("other")), "origin");
1342        assert_eq!(configured_remote_for_pull(&config, None), "origin");
1343        assert_eq!(
1344            configured_remote_for_pull(&crate::config::ConfigSet::new(), Some("main")),
1345            "origin"
1346        );
1347    }
1348
1349    #[test]
1350    fn configured_remote_for_fetch_skips_local_upstream() {
1351        use super::configured_remote_for_fetch;
1352
1353        let config = branch_remote_config(BRANCH_REMOTES);
1354        assert_eq!(configured_remote_for_fetch(&config, Some("main")), "backup");
1355        assert_eq!(
1356            configured_remote_for_fetch(&config, Some("local")),
1357            "origin"
1358        );
1359        assert_eq!(
1360            configured_remote_for_fetch(&config, Some("blank")),
1361            "origin"
1362        );
1363        assert_eq!(
1364            configured_remote_for_fetch(&config, Some("padded")),
1365            "backup"
1366        );
1367        assert_eq!(configured_remote_for_fetch(&config, None), "origin");
1368    }
1369
1370    #[test]
1371    fn configured_remotes_ignore_push_remotes() {
1372        use super::{configured_remote_for_fetch, configured_remote_for_pull};
1373
1374        let config = branch_remote_config(
1375            "[remote]\n\tpushDefault = backup\n[branch \"main\"]\n\tpushRemote = backup\n",
1376        );
1377        assert_eq!(configured_remote_for_fetch(&config, Some("main")), "origin");
1378        assert_eq!(configured_remote_for_pull(&config, Some("main")), "origin");
1379    }
1380
1381    #[test]
1382    fn local_upstream_defaults_differ_between_fetch_and_pull() {
1383        use super::{resolve_fetch_invocation, resolve_pull_invocation, FetchInvocation};
1384
1385        let config = branch_remote_config(BRANCH_REMOTES);
1386        assert_eq!(
1387            resolve_fetch_invocation(&config, None, &[], Some("local")),
1388            FetchInvocation {
1389                remote: "origin".into(),
1390                user_refspecs: vec![],
1391            }
1392        );
1393        assert_eq!(
1394            resolve_pull_invocation(&config, None, &[], Some("local")),
1395            FetchInvocation {
1396                remote: ".".into(),
1397                user_refspecs: vec![],
1398            }
1399        );
1400        assert_eq!(
1401            resolve_pull_invocation(&config, None, &["backup".into()], Some("local")),
1402            FetchInvocation {
1403                remote: "backup".into(),
1404                user_refspecs: vec![],
1405            }
1406        );
1407        assert_eq!(
1408            resolve_pull_invocation(&config, None, &[], Some("main")),
1409            FetchInvocation {
1410                remote: "backup".into(),
1411                user_refspecs: vec![],
1412            }
1413        );
1414    }
1415
1416    #[test]
1417    fn resolve_fetch_invocation_defaults_to_current_branch_remote() {
1418        use super::{resolve_fetch_invocation, FetchInvocation};
1419
1420        let config = branch_remote_config(
1421            "[remote \"origin\"]\n\turl = /tmp/a.git\n[remote \"backup\"]\n\turl = /tmp/b.git\n\
1422             [branch \"main\"]\n\tremote = backup\n",
1423        );
1424        assert_eq!(
1425            resolve_fetch_invocation(&config, None, &[], Some("main")),
1426            FetchInvocation {
1427                remote: "backup".into(),
1428                user_refspecs: vec![],
1429            }
1430        );
1431        assert_eq!(
1432            resolve_fetch_invocation(&config, None, &["topic".into()], Some("main")),
1433            FetchInvocation {
1434                remote: "backup".into(),
1435                user_refspecs: vec!["topic".into()],
1436            }
1437        );
1438        assert_eq!(
1439            resolve_fetch_invocation(&config, None, &["origin".into()], Some("main")),
1440            FetchInvocation {
1441                remote: "origin".into(),
1442                user_refspecs: vec![],
1443            }
1444        );
1445        assert_eq!(
1446            resolve_fetch_invocation(&config, Some("origin"), &[], Some("main")),
1447            FetchInvocation {
1448                remote: "origin".into(),
1449                user_refspecs: vec![],
1450            }
1451        );
1452        assert_eq!(
1453            resolve_fetch_invocation(&config, None, &[], None),
1454            FetchInvocation {
1455                remote: "origin".into(),
1456                user_refspecs: vec![],
1457            }
1458        );
1459    }
1460}