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