1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum FetchRefspecSourceKind {
22 ExactRemoteRef,
24 DwimHeads,
26 DwimTags,
28 DwimRefsPrefix,
30 DwimLiteral,
32 Head,
34 ObjectId,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum FetchHeadPolicy {
41 ForMerge,
43 Ignore,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ResolvedFetchRef {
50 pub remote_ref: String,
52 pub oid: Option<ObjectId>,
54 pub local_ref: Option<String>,
56 pub force: bool,
58 pub fetch_head: FetchHeadPolicy,
60 pub source_kind: FetchRefspecSourceKind,
62}
63
64#[derive(Debug, Clone)]
66pub enum FetchRefspecPlan {
67 Pattern {
69 positive: Vec<RefspecItem>,
71 negatives: Vec<RefspecItem>,
73 },
74 Mixed {
76 explicit: Vec<ResolvedFetchRef>,
78 pattern_positive: Vec<RefspecItem>,
80 negatives: Vec<RefspecItem>,
82 },
83}
84
85#[derive(Debug, Error, Clone, PartialEq, Eq)]
87pub enum FetchRefspecError {
88 #[error("invalid refspec '{spec}': {message}")]
90 InvalidRefspec {
91 spec: String,
93 message: String,
95 },
96 #[error("remote has no ref matching fetch source '{ref_name}'")]
98 SourceNotFound {
99 ref_name: String,
101 spec: String,
103 },
104 #[error("fetch source '{ref_name}' is ambiguous on the remote")]
106 AmbiguousSource {
107 ref_name: String,
109 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#[derive(Debug, Clone, Copy, Default)]
127pub struct FetchRemoteAdvertisement<'a> {
128 pub refs: &'a [(String, ObjectId)],
130 pub head_oid: Option<ObjectId>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct FetchInvocation {
137 pub remote: String,
139 pub user_refspecs: Vec<String>,
141}
142
143pub 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#[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
180fn 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#[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#[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#[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#[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
299fn 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
313fn 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#[derive(Debug, Clone, Default)]
340pub struct FetchRefspecInput {
341 pub user_refspecs: Vec<String>,
343 pub configured_refspecs: Vec<String>,
345 pub remote_name: Option<String>,
347 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 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 #[must_use]
481 pub fn is_explicit(&self) -> bool {
482 matches!(self, Self::Mixed { .. })
483 }
484}
485
486pub(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
632fn 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
645pub(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#[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#[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#[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}