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 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]
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}