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
167fn 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#[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#[must_use]
204pub(crate) 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#[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#[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
286fn 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
300fn 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#[derive(Debug, Clone, Default)]
327pub struct FetchRefspecInput {
328 pub user_refspecs: Vec<String>,
330 pub configured_refspecs: Vec<String>,
332 pub remote_name: Option<String>,
334 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 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 #[must_use]
468 pub fn is_explicit(&self) -> bool {
469 matches!(self, Self::Mixed { .. })
470 }
471}
472
473pub(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
621fn 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
634pub(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#[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#[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#[must_use]
807#[allow(dead_code)]
808pub(crate) fn refname_match_fetch_dwim(abbrev: &str, full: &str) -> u32 {
809 if abbrev == full {
810 return 100;
811 }
812 if full == format!("refs/heads/{abbrev}") {
813 return 90;
814 }
815 if full == format!("refs/tags/{abbrev}") {
816 return 80;
817 }
818 if full == format!("refs/{abbrev}") {
819 return 70;
820 }
821 0
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827
828 fn remote_refs(names: &[&str]) -> Vec<(String, ObjectId)> {
829 let oid = ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap();
830 names.iter().map(|n| (n.to_string(), oid)).collect()
831 }
832
833 fn adv<'a>(refs: &'a [(String, ObjectId)]) -> FetchRemoteAdvertisement<'a> {
834 FetchRemoteAdvertisement {
835 refs,
836 head_oid: None,
837 }
838 }
839
840 fn matched_from_plan(
841 plan: &FetchRefspecPlan,
842 remote: FetchRemoteAdvertisement<'_>,
843 ) -> Vec<MatchedRef> {
844 let negatives = match plan {
845 FetchRefspecPlan::Pattern { negatives, .. }
846 | FetchRefspecPlan::Mixed { negatives, .. } => negatives.as_slice(),
847 };
848 matched_refs_with_negatives(plan, remote, negatives, false).unwrap()
849 }
850
851 #[test]
852 fn dwim_prefers_heads_over_tags() {
853 let refs = remote_refs(&["refs/heads/main", "refs/tags/main"]);
854 let (name, kind, _) = resolve_explicit_source(
855 "main",
856 &refs.iter().map(|(n, o)| (n.as_str(), *o)).collect(),
857 None,
858 "main",
859 )
860 .unwrap();
861 assert_eq!(name, "refs/heads/main");
862 assert_eq!(kind, FetchRefspecSourceKind::DwimHeads);
863 }
864
865 #[test]
866 fn pull_request_ref_expands_under_refs() {
867 let refs = remote_refs(&["refs/pull/123/head"]);
868 let (name, kind, _) = resolve_explicit_source(
869 "pull/123/head",
870 &refs.iter().map(|(n, o)| (n.as_str(), *o)).collect(),
871 None,
872 "pull/123/head",
873 )
874 .unwrap();
875 assert_eq!(name, "refs/pull/123/head");
876 assert_eq!(kind, FetchRefspecSourceKind::DwimRefsPrefix);
877 }
878
879 #[test]
880 fn head_uses_advertised_head_oid() {
881 let oid = ObjectId::from_hex("00000000000000000000000000000000000000ab").unwrap();
882 let (name, kind, resolved) =
883 resolve_explicit_source("HEAD", &HashMap::new(), Some(oid), "HEAD").unwrap();
884 assert_eq!(name, "HEAD");
885 assert_eq!(kind, FetchRefspecSourceKind::Head);
886 assert_eq!(resolved, Some(oid));
887 }
888
889 #[test]
890 fn mixed_explicit_and_pattern_refspecs() {
891 let refs = remote_refs(&["refs/heads/main", "refs/heads/extra"]);
892 let plan = FetchRefspecPlan::resolve(
893 &FetchRefspecInput {
894 user_refspecs: vec!["main".into(), "+refs/heads/*:refs/remotes/other/*".into()],
895 configured_refspecs: vec![],
896 remote_name: Some("origin".into()),
897 ..Default::default()
898 },
899 adv(&refs),
900 )
901 .unwrap();
902 let matched = matched_from_plan(&plan, adv(&refs));
903 assert!(
904 matched
905 .iter()
906 .any(|m| m.local_ref.as_deref() == Some("refs/remotes/other/main")),
907 "pattern refspec should map main to other/*"
908 );
909 }
910
911 #[test]
912 fn opportunistic_exact_configured_mapping() {
913 let refs = remote_refs(&["refs/heads/main"]);
914 let plan = FetchRefspecPlan::resolve(
915 &FetchRefspecInput {
916 user_refspecs: vec!["main".into()],
917 configured_refspecs: vec!["refs/heads/main:refs/remotes/origin/main".into()],
918 remote_name: Some("origin".into()),
919 ..Default::default()
920 },
921 adv(&refs),
922 )
923 .unwrap();
924 let matched = matched_from_plan(&plan, adv(&refs));
925 assert!(
926 matched
927 .iter()
928 .any(|m| m.local_ref.as_deref() == Some("refs/remotes/origin/main")),
929 "exact configured mapping should opportunistically update tracking ref"
930 );
931 }
932
933 #[test]
934 fn extra_negative_refspec_excludes_from_wildcard_match() {
935 let refs = remote_refs(&["refs/heads/main", "refs/heads/topic", "refs/heads/side"]);
936 let plan = FetchRefspecPlan::resolve(
937 &FetchRefspecInput {
938 user_refspecs: vec!["+refs/heads/*:refs/remotes/origin/*".into()],
939 ..Default::default()
940 },
941 adv(&refs),
942 )
943 .unwrap();
944 let extra = vec![parse_fetch_refspec("^refs/heads/topic").unwrap()];
945 let matched = matched_refs_with_negatives(&plan, adv(&refs), &extra, false).unwrap();
946 assert!(
947 matched
948 .iter()
949 .any(|m| m.local_ref.as_deref() == Some("refs/remotes/origin/main")),
950 "main must match"
951 );
952 assert!(
953 matched
954 .iter()
955 .any(|m| m.local_ref.as_deref() == Some("refs/remotes/origin/side")),
956 "side must match"
957 );
958 assert!(
959 !matched
960 .iter()
961 .any(|m| m.local_ref.as_deref() == Some("refs/remotes/origin/topic")),
962 "negative refspec must drop topic"
963 );
964 }
965
966 #[test]
967 fn explicit_remote_ref_for_prune_keeps_head_symbol() {
968 assert_eq!(explicit_remote_ref_for_prune("HEAD"), "HEAD");
969 assert_eq!(explicit_remote_ref_for_prune("@"), "HEAD");
970 assert_eq!(explicit_remote_ref_for_prune(""), "HEAD");
971 assert_eq!(explicit_remote_ref_for_prune("topic"), "refs/heads/topic");
972 }
973
974 #[test]
975 fn vanished_head_prune_does_not_match_refs_heads_head_branch() {
976 let oid = ObjectId::from_hex("0000000000000000000000000000000000000002").unwrap();
977 let refs = vec![("refs/heads/HEAD".to_string(), oid)];
978 let plan = FetchRefspecPlan::resolve(
979 &FetchRefspecInput {
980 user_refspecs: vec!["HEAD".into()],
981 allow_missing_explicit_for_prune: true,
982 ..Default::default()
983 },
984 FetchRemoteAdvertisement {
985 refs: &refs,
986 head_oid: None,
987 },
988 )
989 .unwrap();
990 let matched = matched_refs_with_negatives(
991 &plan,
992 FetchRemoteAdvertisement {
993 refs: &refs,
994 head_oid: None,
995 },
996 &[],
997 true,
998 )
999 .unwrap();
1000 assert!(
1001 matched.is_empty(),
1002 "vanished HEAD must not be confused with a refs/heads/HEAD branch"
1003 );
1004 }
1005
1006 #[test]
1007 fn supplement_positive_adds_tracking_dst_for_short_explicit_prune() {
1008 let mut positive = vec![parse_fetch_refspec("topic").unwrap()];
1009 supplement_positive_for_explicit_prune(
1010 &mut positive,
1011 &["+refs/heads/*:refs/remotes/origin/*".into()],
1012 Some("origin"),
1013 );
1014 assert!(
1015 positive.iter().any(|i| {
1016 i.src.as_deref() == Some("topic")
1017 && i.dst.as_deref() == Some("refs/remotes/origin/topic")
1018 }),
1019 "short explicit refspec must gain configured tracking dst for prune"
1020 );
1021 }
1022
1023 #[test]
1024 fn prune_skips_missing_explicit_source_instead_of_error() {
1025 let refs = remote_refs(&["refs/heads/main"]);
1026 let plan = FetchRefspecPlan::resolve(
1027 &FetchRefspecInput {
1028 user_refspecs: vec!["+refs/heads/topic:refs/remotes/origin/topic".into()],
1029 configured_refspecs: vec![],
1030 remote_name: None,
1031 allow_missing_explicit_for_prune: true,
1032 },
1033 adv(&refs),
1034 )
1035 .unwrap();
1036 let matched = matched_refs_with_negatives(&plan, adv(&refs), &[], true).unwrap();
1037 assert!(
1038 matched.is_empty(),
1039 "vanished explicit source with prune must not error or match"
1040 );
1041 }
1042
1043 #[test]
1044 fn duplicate_remote_ref_two_destinations() {
1045 let refs = remote_refs(&["refs/heads/main"]);
1046 let plan = FetchRefspecPlan::resolve(
1047 &FetchRefspecInput {
1048 user_refspecs: vec!["main:refs/heads/a".into(), "main:refs/heads/b".into()],
1049 ..Default::default()
1050 },
1051 adv(&refs),
1052 )
1053 .unwrap();
1054 let matched = matched_from_plan(&plan, adv(&refs));
1055 assert_eq!(
1056 matched
1057 .iter()
1058 .filter(|m| m.remote_ref == "refs/heads/main")
1059 .count(),
1060 2
1061 );
1062 }
1063
1064 #[test]
1065 fn object_id_refspec_carries_oid() {
1066 let oid = ObjectId::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
1067 let refs: Vec<(String, ObjectId)> = vec![];
1068 let plan = FetchRefspecPlan::resolve(
1069 &FetchRefspecInput {
1070 user_refspecs: vec![format!("{oid}:refs/heads/imported")],
1071 ..Default::default()
1072 },
1073 adv(&refs),
1074 )
1075 .unwrap();
1076 let matched = matched_from_plan(&plan, adv(&refs));
1077 assert_eq!(matched.len(), 1);
1078 assert_eq!(matched[0].oid, oid);
1079 }
1080
1081 #[test]
1082 fn short_destination_maps_to_refs_heads() {
1083 assert_eq!(
1084 resolve_explicit_destination(Some("pr-123"), "refs/pull/123/head").as_deref(),
1085 Some("refs/heads/pr-123")
1086 );
1087 }
1088
1089 #[test]
1090 fn opportunistic_tracking_adds_origin_main() {
1091 let mut entries = vec![ResolvedFetchRef {
1092 remote_ref: "refs/heads/main".into(),
1093 oid: None,
1094 local_ref: Some("refs/heads/local-main".into()),
1095 force: false,
1096 fetch_head: FetchHeadPolicy::ForMerge,
1097 source_kind: FetchRefspecSourceKind::DwimHeads,
1098 }];
1099 append_opportunistic_tracking(
1100 &mut entries,
1101 &["+refs/heads/*:refs/remotes/origin/*".into()],
1102 Some("origin"),
1103 );
1104 assert!(entries.iter().any(
1105 |e| e.local_ref.as_deref() == Some("refs/remotes/origin/main")
1106 && e.fetch_head == FetchHeadPolicy::Ignore
1107 ));
1108 }
1109
1110 #[test]
1111 fn refname_match_fetch_dwim_ordering() {
1112 assert!(
1113 refname_match_fetch_dwim("main", "refs/heads/main")
1114 > refname_match_fetch_dwim("main", "refs/tags/main")
1115 );
1116 }
1117
1118 #[test]
1119 fn colon_refspec_is_not_treated_as_remote() {
1120 use super::{resolve_fetch_invocation, FetchInvocation};
1121 use crate::config::{ConfigFile, ConfigScope, ConfigSet};
1122
1123 let file = ConfigFile::parse(
1124 std::path::Path::new("cfg"),
1125 "[remote \"origin\"]\n\turl = /tmp/x.git\n",
1126 ConfigScope::Local,
1127 )
1128 .expect("parse");
1129 let mut config = ConfigSet::new();
1130 config.merge(&file);
1131
1132 assert_eq!(
1133 resolve_fetch_invocation(&config, None, &["main:refs/heads/feature".into()], None),
1134 FetchInvocation {
1135 remote: "origin".into(),
1136 user_refspecs: vec!["main:refs/heads/feature".into()],
1137 }
1138 );
1139 }
1140
1141 #[test]
1142 fn explicit_remote_positional_includes_scp_style_urls() {
1143 use super::{resolve_fetch_invocation, FetchInvocation};
1144 use crate::config::ConfigSet;
1145
1146 let config = ConfigSet::new();
1147 assert_eq!(
1148 resolve_fetch_invocation(&config, Some("example.com:repo"), &[], None),
1149 FetchInvocation {
1150 remote: "example.com:repo".into(),
1151 user_refspecs: vec![],
1152 }
1153 );
1154 assert_eq!(
1155 resolve_fetch_invocation(&config, Some("git@host:org/repo"), &[], None),
1156 FetchInvocation {
1157 remote: "git@host:org/repo".into(),
1158 user_refspecs: vec![],
1159 }
1160 );
1161 }
1162
1163 #[test]
1164 fn resolve_remote_argument_prefers_configured_then_url_forms() {
1165 use crate::config::{ConfigFile, ConfigScope, ConfigSet};
1166 use crate::remote::RemoteUrl;
1167
1168 let file = ConfigFile::parse(
1169 std::path::Path::new("cfg"),
1170 "[remote \"origin\"]\n\turl = /tmp/x.git\n[remote \"nourl\"]\n\tfetch = +refs/heads/*:refs/remotes/nourl/*\n",
1171 ConfigScope::Local,
1172 )
1173 .expect("parse");
1174 let mut config = ConfigSet::new();
1175 config.merge(&file);
1176
1177 let origin = resolve_remote_argument(&config, "origin").expect("origin");
1178 assert_eq!(origin.name.as_deref(), Some("origin"));
1179
1180 for url in [
1181 "https://example.com/r.git",
1182 "./sibling",
1183 "../sibling",
1184 "/abs/repo",
1185 "git@host:org/repo",
1186 "host:repo",
1187 ] {
1188 let remote = resolve_remote_argument(&config, url).expect(url);
1189 assert_eq!(remote.name, None, "{url}");
1190 }
1191 assert!(matches!(
1192 resolve_remote_argument(&config, "host:repo").map(|r| r.fetch_url().clone()),
1193 Ok(RemoteUrl::Ssh(_))
1194 ));
1195
1196 assert!(matches!(
1197 resolve_remote_argument(&config, "nourl"),
1198 Err(RemoteError::NoUrl { remote: Some(name) }) if name == "nourl"
1199 ));
1200 let err = resolve_remote_argument(&config, "nosuch").expect_err("unknown");
1201 assert!(matches!(&err, RemoteError::UnknownRemote(name) if name == "nosuch"));
1202 assert_eq!(err.to_string(), "unknown remote 'nosuch'");
1203 }
1204
1205 #[test]
1206 fn url_less_configured_remote_is_consumed_as_the_remote() {
1207 use super::{resolve_fetch_invocation, FetchInvocation};
1208 use crate::config::{ConfigFile, ConfigScope, ConfigSet};
1209
1210 let file = ConfigFile::parse(
1211 std::path::Path::new("cfg"),
1212 "[remote \"half\"]\n\tfetch = +refs/heads/*:refs/remotes/half/*\n",
1213 ConfigScope::Local,
1214 )
1215 .expect("parse");
1216 let mut config = ConfigSet::new();
1217 config.merge(&file);
1218
1219 let invocation =
1220 resolve_fetch_invocation(&config, None, &["half".into(), "main".into()], None);
1221 assert_eq!(
1222 invocation,
1223 FetchInvocation {
1224 remote: "half".into(),
1225 user_refspecs: vec!["main".into()],
1226 }
1227 );
1228 assert!(matches!(
1229 resolve_remote_argument(&config, &invocation.remote),
1230 Err(RemoteError::NoUrl { remote: Some(name) }) if name == "half"
1231 ));
1232 }
1233
1234 #[test]
1235 fn configured_remote_with_colon_refspec() {
1236 use super::{resolve_fetch_invocation, FetchInvocation};
1237 use crate::config::{ConfigFile, ConfigScope, ConfigSet};
1238
1239 let file = ConfigFile::parse(
1240 std::path::Path::new("cfg"),
1241 "[remote \"origin\"]\n\turl = /tmp/x.git\n",
1242 ConfigScope::Local,
1243 )
1244 .expect("parse");
1245 let mut config = ConfigSet::new();
1246 config.merge(&file);
1247
1248 assert_eq!(
1249 resolve_fetch_invocation(&config, Some("origin"), &["a:b".into()], None),
1250 FetchInvocation {
1251 remote: "origin".into(),
1252 user_refspecs: vec!["a:b".into()],
1253 }
1254 );
1255 }
1256
1257 #[test]
1258 fn resolve_fetch_invocation_remote_vs_refspec() {
1259 use super::{resolve_fetch_invocation, FetchInvocation};
1260 use crate::config::{ConfigFile, ConfigScope, ConfigSet};
1261
1262 let file = ConfigFile::parse(
1263 std::path::Path::new("cfg"),
1264 "[remote \"origin\"]\n\turl = /tmp/x.git\n",
1265 ConfigScope::Local,
1266 )
1267 .expect("parse");
1268 let mut config = ConfigSet::new();
1269 config.merge(&file);
1270
1271 assert_eq!(
1272 resolve_fetch_invocation(&config, Some("origin"), &["main".into()], None),
1273 FetchInvocation {
1274 remote: "origin".into(),
1275 user_refspecs: vec!["main".into()],
1276 }
1277 );
1278 assert_eq!(
1279 resolve_fetch_invocation(&config, None, &["main".into()], None),
1280 FetchInvocation {
1281 remote: "origin".into(),
1282 user_refspecs: vec!["main".into()],
1283 }
1284 );
1285
1286 let refspec = "refs/heads/main:refs/remotes/origin/main".to_owned();
1287 assert_eq!(
1288 resolve_fetch_invocation(&config, None, &[refspec.clone()], None),
1289 FetchInvocation {
1290 remote: "origin".into(),
1291 user_refspecs: vec![refspec],
1292 }
1293 );
1294
1295 assert!(super::is_fetch_remote_token(
1296 &config,
1297 "git@github.com:org/repo.git"
1298 ));
1299 assert!(!super::looks_like_fetch_refspec(
1300 "git@github.com:org/repo.git"
1301 ));
1302 }
1303
1304 fn branch_remote_config(body: &str) -> crate::config::ConfigSet {
1305 use crate::config::{ConfigFile, ConfigScope, ConfigSet};
1306
1307 let file = ConfigFile::parse(std::path::Path::new("cfg"), body, ConfigScope::Local)
1308 .expect("parse");
1309 let mut config = ConfigSet::new();
1310 config.merge(&file);
1311 config
1312 }
1313
1314 const BRANCH_REMOTES: &str =
1315 "[remote \"origin\"]\n\turl = /tmp/a.git\n[remote \"backup\"]\n\turl = /tmp/b.git\n\
1316 [branch \"main\"]\n\tremote = backup\n[branch \"local\"]\n\tremote = .\n\
1317 [branch \"blank\"]\n\tremote = \" \"\n[branch \"padded\"]\n\tremote = \" backup \"\n";
1318
1319 #[test]
1320 fn configured_remote_for_pull_uses_branch_remote_then_origin() {
1321 use super::configured_remote_for_pull;
1322
1323 let config = branch_remote_config(BRANCH_REMOTES);
1324 assert_eq!(configured_remote_for_pull(&config, Some("main")), "backup");
1325 assert_eq!(configured_remote_for_pull(&config, Some("local")), ".");
1326 assert_eq!(configured_remote_for_pull(&config, Some("blank")), "origin");
1327 assert_eq!(
1328 configured_remote_for_pull(&config, Some("padded")),
1329 "backup"
1330 );
1331 assert_eq!(configured_remote_for_pull(&config, Some("other")), "origin");
1332 assert_eq!(configured_remote_for_pull(&config, None), "origin");
1333 assert_eq!(
1334 configured_remote_for_pull(&crate::config::ConfigSet::new(), Some("main")),
1335 "origin"
1336 );
1337 }
1338
1339 #[test]
1340 fn configured_remote_for_fetch_skips_local_upstream() {
1341 use super::configured_remote_for_fetch;
1342
1343 let config = branch_remote_config(BRANCH_REMOTES);
1344 assert_eq!(configured_remote_for_fetch(&config, Some("main")), "backup");
1345 assert_eq!(
1346 configured_remote_for_fetch(&config, Some("local")),
1347 "origin"
1348 );
1349 assert_eq!(
1350 configured_remote_for_fetch(&config, Some("blank")),
1351 "origin"
1352 );
1353 assert_eq!(
1354 configured_remote_for_fetch(&config, Some("padded")),
1355 "backup"
1356 );
1357 assert_eq!(configured_remote_for_fetch(&config, None), "origin");
1358 }
1359
1360 #[test]
1361 fn configured_remotes_ignore_push_remotes() {
1362 use super::{configured_remote_for_fetch, configured_remote_for_pull};
1363
1364 let config = branch_remote_config(
1365 "[remote]\n\tpushDefault = backup\n[branch \"main\"]\n\tpushRemote = backup\n",
1366 );
1367 assert_eq!(configured_remote_for_fetch(&config, Some("main")), "origin");
1368 assert_eq!(configured_remote_for_pull(&config, Some("main")), "origin");
1369 }
1370
1371 #[test]
1372 fn local_upstream_defaults_differ_between_fetch_and_pull() {
1373 use super::{resolve_fetch_invocation, resolve_pull_invocation, FetchInvocation};
1374
1375 let config = branch_remote_config(BRANCH_REMOTES);
1376 assert_eq!(
1377 resolve_fetch_invocation(&config, None, &[], Some("local")),
1378 FetchInvocation {
1379 remote: "origin".into(),
1380 user_refspecs: vec![],
1381 }
1382 );
1383 assert_eq!(
1384 resolve_pull_invocation(&config, None, &[], Some("local")),
1385 FetchInvocation {
1386 remote: ".".into(),
1387 user_refspecs: vec![],
1388 }
1389 );
1390 assert_eq!(
1391 resolve_pull_invocation(&config, None, &["backup".into()], Some("local")),
1392 FetchInvocation {
1393 remote: "backup".into(),
1394 user_refspecs: vec![],
1395 }
1396 );
1397 assert_eq!(
1398 resolve_pull_invocation(&config, None, &[], Some("main")),
1399 FetchInvocation {
1400 remote: "backup".into(),
1401 user_refspecs: vec![],
1402 }
1403 );
1404 }
1405
1406 #[test]
1407 fn resolve_fetch_invocation_defaults_to_current_branch_remote() {
1408 use super::{resolve_fetch_invocation, FetchInvocation};
1409
1410 let config = branch_remote_config(
1411 "[remote \"origin\"]\n\turl = /tmp/a.git\n[remote \"backup\"]\n\turl = /tmp/b.git\n\
1412 [branch \"main\"]\n\tremote = backup\n",
1413 );
1414 assert_eq!(
1415 resolve_fetch_invocation(&config, None, &[], Some("main")),
1416 FetchInvocation {
1417 remote: "backup".into(),
1418 user_refspecs: vec![],
1419 }
1420 );
1421 assert_eq!(
1422 resolve_fetch_invocation(&config, None, &["topic".into()], Some("main")),
1423 FetchInvocation {
1424 remote: "backup".into(),
1425 user_refspecs: vec!["topic".into()],
1426 }
1427 );
1428 assert_eq!(
1429 resolve_fetch_invocation(&config, None, &["origin".into()], Some("main")),
1430 FetchInvocation {
1431 remote: "origin".into(),
1432 user_refspecs: vec![],
1433 }
1434 );
1435 assert_eq!(
1436 resolve_fetch_invocation(&config, Some("origin"), &[], Some("main")),
1437 FetchInvocation {
1438 remote: "origin".into(),
1439 user_refspecs: vec![],
1440 }
1441 );
1442 assert_eq!(
1443 resolve_fetch_invocation(&config, None, &[], None),
1444 FetchInvocation {
1445 remote: "origin".into(),
1446 user_refspecs: vec![],
1447 }
1448 );
1449 }
1450}