1use std::collections::BTreeSet;
18use std::path::Path;
19
20use crate::{ConfigEntry, ConfigSection, GitConfig};
21
22pub fn default_fetch_refspec(name: &str) -> String {
25 format!("+refs/heads/*:refs/remotes/{name}/*")
26}
27
28pub fn remote_names(config: &GitConfig) -> Vec<String> {
35 config
36 .sections
37 .iter()
38 .filter(|section| section.name == "remote")
39 .filter_map(|section| section.subsection.clone())
40 .collect::<BTreeSet<_>>()
41 .into_iter()
42 .collect()
43}
44
45pub fn remote_config_values(config: &GitConfig, name: &str, key: &str) -> Vec<String> {
47 config
48 .sections
49 .iter()
50 .filter(|section| section.name == "remote" && section.subsection.as_deref() == Some(name))
51 .flat_map(|section| {
52 section
53 .entries
54 .iter()
55 .filter(move |entry| entry.key.eq_ignore_ascii_case(key))
56 .filter_map(|entry| entry.value.clone())
57 })
58 .collect()
59}
60
61pub fn rewrite_url_with_config(config: &GitConfig, url: &str, push: bool) -> String {
64 let mut best: Option<(&str, &str, u8)> = None;
65 for section in &config.sections {
66 if section.name != "url" {
67 continue;
68 }
69 let Some(base) = section.subsection.as_deref() else {
70 continue;
71 };
72 for entry in §ion.entries {
73 let priority = if push && entry.key.eq_ignore_ascii_case("pushInsteadOf") {
74 2
75 } else if entry.key.eq_ignore_ascii_case("insteadOf") {
76 1
77 } else {
78 continue;
79 };
80 let Some(prefix) = entry.value.as_deref() else {
81 continue;
82 };
83 if !url.starts_with(prefix) {
84 continue;
85 }
86 let replace = match best {
87 None => true,
88 Some((_, best_prefix, best_priority)) => {
89 priority > best_priority
90 || (priority == best_priority && prefix.len() > best_prefix.len())
91 }
92 };
93 if replace {
94 best = Some((base, prefix, priority));
95 }
96 }
97 }
98 if let Some((base, prefix, _)) = best {
99 format!("{base}{}", &url[prefix.len()..])
100 } else {
101 url.to_string()
102 }
103}
104
105pub fn resolve_remote_fetch_url(config: &GitConfig, remote: &str) -> String {
109 let url = remote_config_values(config, remote, "url")
110 .into_iter()
111 .next()
112 .unwrap_or_else(|| remote.to_string());
113 rewrite_url_with_config(config, &url, false)
114}
115
116pub fn resolve_remote_push_url(config: &GitConfig, remote: &str) -> String {
122 if let Some(url) = remote_config_values(config, remote, "pushurl")
123 .into_iter()
124 .next()
125 {
126 return rewrite_url_with_config(config, &url, false);
127 }
128 let url = remote_config_values(config, remote, "url")
129 .into_iter()
130 .next()
131 .unwrap_or_else(|| remote.to_string());
132 rewrite_url_with_config(config, &url, true)
133}
134
135pub fn remote_exists(config: &GitConfig, name: &str) -> bool {
138 config
139 .sections
140 .iter()
141 .any(|section| section.name == "remote" && section.subsection.as_deref() == Some(name))
142}
143
144fn valid_remote_nick(name: &str) -> bool {
149 !name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')
150}
151
152fn default_branch_name(config: &GitConfig) -> String {
157 if let Ok(name) = std::env::var("GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME")
158 && !name.is_empty()
159 {
160 return name;
161 }
162 config
163 .get("init", None, "defaultBranch")
164 .filter(|name| !name.is_empty())
165 .unwrap_or("master")
166 .to_string()
167}
168
169pub fn augment_with_legacy_remote_files(config: &mut GitConfig, git_dir: &Path) {
190 let mut new_sections = Vec::new();
191 augment_from_remotes_dir(config, git_dir, &mut new_sections);
192 augment_from_branches_dir(config, git_dir, &mut new_sections);
193 config.sections.extend(new_sections);
194}
195
196fn augment_from_remotes_dir(config: &GitConfig, git_dir: &Path, out: &mut Vec<ConfigSection>) {
197 let dir = git_dir.join("remotes");
198 let Ok(entries) = std::fs::read_dir(&dir) else {
199 return;
200 };
201 for entry in entries.flatten() {
202 let Ok(name) = entry.file_name().into_string() else {
203 continue;
204 };
205 if !valid_remote_nick(&name) || remote_exists(config, &name) {
206 continue;
207 }
208 let Ok(contents) = std::fs::read_to_string(entry.path()) else {
209 continue;
210 };
211 let mut section_entries = Vec::new();
212 for line in contents.lines() {
213 let line = line.trim_end();
214 if let Some(url) = line.strip_prefix("URL:") {
215 section_entries.push(ConfigEntry::new("url", Some(url.trim_start().to_string())));
216 } else if let Some(spec) = line.strip_prefix("Pull:") {
217 section_entries.push(ConfigEntry::new(
218 "fetch",
219 Some(spec.trim_start().to_string()),
220 ));
221 } else if let Some(spec) = line.strip_prefix("Push:") {
222 section_entries.push(ConfigEntry::new(
223 "push",
224 Some(spec.trim_start().to_string()),
225 ));
226 }
227 }
228 if section_entries.iter().any(|e| e.key == "url") {
229 out.push(ConfigSection::new("remote", Some(name), section_entries));
230 }
231 }
232}
233
234fn augment_from_branches_dir(config: &GitConfig, git_dir: &Path, out: &mut Vec<ConfigSection>) {
235 let dir = git_dir.join("branches");
236 let Ok(entries) = std::fs::read_dir(&dir) else {
237 return;
238 };
239 let default_branch = default_branch_name(config);
240 for entry in entries.flatten() {
241 let Ok(name) = entry.file_name().into_string() else {
242 continue;
243 };
244 if !valid_remote_nick(&name)
245 || remote_exists(config, &name)
246 || out
247 .iter()
248 .any(|s| s.subsection.as_deref() == Some(name.as_str()))
249 {
250 continue;
251 }
252 let Ok(contents) = std::fs::read_to_string(entry.path()) else {
253 continue;
254 };
255 let first = contents.lines().next().unwrap_or("").trim();
256 if first.is_empty() {
257 continue;
258 }
259 let (url, frag) = match first.split_once('#') {
260 Some((url, frag)) => (url, frag.to_string()),
261 None => (first, default_branch.clone()),
262 };
263 let section_entries = vec![
264 ConfigEntry::new("url", Some(url.to_string())),
265 ConfigEntry::new(
266 "fetch",
267 Some(format!("refs/heads/{frag}:refs/heads/{name}")),
268 ),
269 ConfigEntry::new("push", Some(format!("HEAD:refs/heads/{frag}"))),
270 ConfigEntry::new("tagopt", Some("--tags".to_string())),
273 ];
274 out.push(ConfigSection::new("remote", Some(name), section_entries));
275 }
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
280pub enum RemoteEditError {
281 AlreadyExists,
283 NotFound,
285}
286
287pub fn add_remote(
295 config: &mut GitConfig,
296 name: &str,
297 entries: Vec<ConfigEntry>,
298) -> Result<(), RemoteEditError> {
299 if remote_exists(config, name) {
300 return Err(RemoteEditError::AlreadyExists);
301 }
302 config.sections.push(ConfigSection::new(
303 "remote",
304 Some(name.to_string()),
305 entries,
306 ));
307 Ok(())
308}
309
310pub fn add_remote_with_fetch(
318 config: &mut GitConfig,
319 name: &str,
320 url: &str,
321 fetch_refspecs: &[String],
322) -> Result<(), RemoteEditError> {
323 let mut entries = vec![ConfigEntry::new("url", Some(url.to_string()))];
324 if fetch_refspecs.is_empty() {
325 entries.push(ConfigEntry::new("fetch", Some(default_fetch_refspec(name))));
326 } else {
327 for refspec in fetch_refspecs {
328 entries.push(ConfigEntry::new("fetch", Some(refspec.clone())));
329 }
330 }
331 add_remote(config, name, entries)
332}
333
334pub fn remove_remote(config: &mut GitConfig, name: &str) -> Result<(), RemoteEditError> {
348 let before = config.sections.len();
349 config.sections.retain(|section| {
350 !(section.name == "remote" && section.subsection.as_deref() == Some(name))
351 });
352 if config.sections.len() == before {
353 return Err(RemoteEditError::NotFound);
354 }
355 remove_remote_dependent_config(config, name);
356 Ok(())
357}
358
359fn remove_remote_dependent_config(config: &mut GitConfig, remote: &str) {
364 for section in &mut config.sections {
365 if section.name == "branch" {
366 let remote_matches = section.entries.iter().any(|entry| {
367 entry.key.eq_ignore_ascii_case("remote") && entry.value.as_deref() == Some(remote)
368 });
369 section.entries.retain(|entry| {
370 let key = entry.key.to_ascii_lowercase();
371 if remote_matches && (key == "remote" || key == "merge") {
372 return false;
373 }
374 !(key == "pushremote" && entry.value.as_deref() == Some(remote))
375 });
376 } else if section.name == "remote" && section.subsection.is_none() {
377 section.entries.retain(|entry| {
378 !(entry.key.eq_ignore_ascii_case("pushDefault")
379 && entry.value.as_deref() == Some(remote))
380 });
381 }
382 }
383 config.sections.retain(|section| {
384 !((section.name == "branch" || (section.name == "remote" && section.subsection.is_none()))
385 && section.entries.is_empty())
386 });
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum SetUrlKind {
392 Fetch,
394 Push,
396}
397
398impl SetUrlKind {
399 pub fn key(self) -> &'static str {
401 match self {
402 SetUrlKind::Fetch => "url",
403 SetUrlKind::Push => "pushurl",
404 }
405 }
406}
407
408pub enum SetUrlOp<'a> {
414 Add { url: &'a str },
416 Delete { matches: &'a dyn Fn(&str) -> bool },
418 Replace {
421 url: &'a str,
422 matches: &'a dyn Fn(&str) -> bool,
423 },
424 Set { url: &'a str },
427}
428
429#[derive(Debug, Clone, PartialEq, Eq)]
435pub enum SetUrlError {
436 RemoteNotFound,
438 NoMatch,
440 DeleteNoMatch,
442 DeleteAllFetchUrls,
444 MultipleValues,
446}
447
448pub fn set_url(
457 config: &mut GitConfig,
458 name: &str,
459 kind: SetUrlKind,
460 op: SetUrlOp<'_>,
461) -> Result<(), SetUrlError> {
462 let key = kind.key();
463 let Some(section) =
464 config.sections.iter_mut().rev().find(|section| {
465 section.name == "remote" && section.subsection.as_deref() == Some(name)
466 })
467 else {
468 return Err(SetUrlError::RemoteNotFound);
469 };
470 match op {
471 SetUrlOp::Add { url } => {
472 section
473 .entries
474 .push(ConfigEntry::new(key, Some(url.to_string())));
475 Ok(())
476 }
477 SetUrlOp::Delete { matches } => set_url_delete(section, kind, key, matches),
478 SetUrlOp::Replace { url, matches } => set_url_replace(section, key, url, matches),
479 SetUrlOp::Set { url } => set_url_set(section, key, url),
480 }
481}
482
483fn set_url_delete(
484 section: &mut ConfigSection,
485 kind: SetUrlKind,
486 key: &str,
487 matches: &dyn Fn(&str) -> bool,
488) -> Result<(), SetUrlError> {
489 let matched = section
490 .entries
491 .iter()
492 .filter(|entry| entry.key.eq_ignore_ascii_case(key))
493 .filter(|entry| entry.value.as_deref().is_some_and(matches))
494 .count();
495 if matched == 0 {
496 return Err(SetUrlError::DeleteNoMatch);
497 }
498 if kind == SetUrlKind::Fetch {
499 let remaining = section
500 .entries
501 .iter()
502 .filter(|entry| entry.key.eq_ignore_ascii_case(key))
503 .filter(|entry| entry.value.as_deref().is_none_or(|value| !matches(value)))
504 .count();
505 if remaining == 0 {
506 return Err(SetUrlError::DeleteAllFetchUrls);
507 }
508 }
509 section.entries.retain(|entry| {
510 !(entry.key.eq_ignore_ascii_case(key) && entry.value.as_deref().is_some_and(matches))
511 });
512 Ok(())
513}
514
515fn set_url_replace(
516 section: &mut ConfigSection,
517 key: &str,
518 url: &str,
519 matches: &dyn Fn(&str) -> bool,
520) -> Result<(), SetUrlError> {
521 let indices = section
522 .entries
523 .iter()
524 .enumerate()
525 .filter(|(_, entry)| entry.key.eq_ignore_ascii_case(key))
526 .filter(|(_, entry)| entry.value.as_deref().is_some_and(matches))
527 .map(|(idx, _)| idx)
528 .collect::<Vec<_>>();
529 match indices.as_slice() {
530 [idx] => {
531 section.entries[*idx].value = Some(url.to_string());
532 Ok(())
533 }
534 [] => Err(SetUrlError::NoMatch),
535 _ => Err(SetUrlError::MultipleValues),
536 }
537}
538
539fn set_url_set(section: &mut ConfigSection, key: &str, url: &str) -> Result<(), SetUrlError> {
540 let indices = section
541 .entries
542 .iter()
543 .enumerate()
544 .filter(|(_, entry)| entry.key.eq_ignore_ascii_case(key))
545 .map(|(idx, _)| idx)
546 .collect::<Vec<_>>();
547 if indices.len() > 1 {
548 return Err(SetUrlError::MultipleValues);
549 }
550 if let Some(idx) = indices.first().copied() {
551 section.entries[idx].value = Some(url.to_string());
552 } else {
553 section
554 .entries
555 .insert(0, ConfigEntry::new(key, Some(url.to_string())));
556 }
557 Ok(())
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 fn config_from(text: &str) -> GitConfig {
565 GitConfig::parse(text.as_bytes()).expect("parse config")
566 }
567
568 fn render(config: &GitConfig) -> String {
569 String::from_utf8(config.to_canonical_bytes()).expect("utf8 config")
570 }
571
572 #[test]
573 fn remote_names_are_sorted_and_deduped() {
574 let config = config_from(
575 "[remote \"origin\"]\n\turl = a\n\
576 [remote \"upstream\"]\n\turl = b\n\
577 [remote \"origin\"]\n\tpushurl = c\n",
578 );
579 assert_eq!(remote_names(&config), vec!["origin", "upstream"]);
580 }
581
582 #[test]
583 fn remote_config_values_preserve_config_order() {
584 let config = config_from(
585 "[remote \"origin\"]\n\turl = first\n\turl = second\n\
586 [remote \"origin\"]\n\turl = third\n\tpushurl = push\n",
587 );
588 assert_eq!(
589 remote_config_values(&config, "origin", "url"),
590 vec!["first", "second", "third"]
591 );
592 assert_eq!(
593 remote_config_values(&config, "origin", "pushurl"),
594 vec!["push"]
595 );
596 }
597
598 #[test]
599 fn rewrite_url_uses_longest_insteadof_match() {
600 let config = config_from(
601 "[url \"ssh://example.com/\"]\n\tinsteadOf = ex:\n\
602 [url \"ssh://example.com/specific/\"]\n\tinsteadOf = ex:specific/\n",
603 );
604 assert_eq!(
605 rewrite_url_with_config(&config, "ex:specific/repo.git", false),
606 "ssh://example.com/specific/repo.git"
607 );
608 }
609
610 #[test]
611 fn rewrite_url_prefers_pushinsteadof_for_pushes() {
612 let config = config_from(
613 "[url \"https://example.com/\"]\n\tinsteadOf = ex:\n\
614 [url \"ssh://push.example.com/\"]\n\tpushInsteadOf = ex:\n",
615 );
616 assert_eq!(
617 rewrite_url_with_config(&config, "ex:repo.git", false),
618 "https://example.com/repo.git"
619 );
620 assert_eq!(
621 rewrite_url_with_config(&config, "ex:repo.git", true),
622 "ssh://push.example.com/repo.git"
623 );
624 }
625
626 #[test]
627 fn resolve_remote_fetch_and_push_urls_apply_precedence_and_rewrites() {
628 let config = config_from(
629 "[url \"https://fetch.example/\"]\n\tinsteadOf = short:\n\
630 [url \"ssh://push.example/\"]\n\tpushInsteadOf = short:\n\
631 [remote \"origin\"]\n\turl = short:repo.git\n\tpushurl = short:push.git\n",
632 );
633 assert_eq!(
634 resolve_remote_fetch_url(&config, "origin"),
635 "https://fetch.example/repo.git"
636 );
637 assert_eq!(
638 resolve_remote_push_url(&config, "origin"),
639 "https://fetch.example/push.git"
640 );
641 }
642
643 #[test]
644 fn resolve_remote_push_url_falls_back_to_fetch_url() {
645 let config = config_from(
646 "[url \"ssh://push.example/\"]\n\tpushInsteadOf = short:\n\
647 [remote \"origin\"]\n\turl = short:repo.git\n",
648 );
649 assert_eq!(
650 resolve_remote_push_url(&config, "origin"),
651 "ssh://push.example/repo.git"
652 );
653 }
654
655 #[test]
656 fn resolve_literal_remote_url_is_rewritten_when_no_remote_exists() {
657 let config = config_from("[url \"ssh://example/\"]\n\tinsteadOf = ex:\n");
658 assert_eq!(
659 resolve_remote_fetch_url(&config, "ex:repo.git"),
660 "ssh://example/repo.git"
661 );
662 }
663
664 #[test]
665 fn add_remote_writes_url_and_default_fetch() {
666 let mut config = GitConfig::default();
667 add_remote_with_fetch(&mut config, "origin", "https://example/x.git", &[])
668 .expect("add remote");
669 assert_eq!(
670 render(&config),
671 "[remote \"origin\"]\n\turl = https://example/x.git\n\
672 \tfetch = +refs/heads/*:refs/remotes/origin/*\n"
673 );
674 }
675
676 #[test]
677 fn add_remote_uses_supplied_fetch_refspecs() {
678 let mut config = GitConfig::default();
679 let specs = vec!["+refs/heads/main:refs/remotes/origin/main".to_string()];
680 add_remote_with_fetch(&mut config, "origin", "url", &specs).expect("add remote");
681 assert_eq!(
682 config.get_all("remote", Some("origin"), "fetch"),
683 vec![Some("+refs/heads/main:refs/remotes/origin/main")]
684 );
685 }
686
687 #[test]
688 fn add_remote_accepts_mirror_style_entries() {
689 let mut config = GitConfig::default();
690 add_remote(
691 &mut config,
692 "origin",
693 vec![
694 ConfigEntry::new("fetch", Some("+refs/*:refs/*".into())),
695 ConfigEntry::new("mirror", Some("true".into())),
696 ],
697 )
698 .expect("add mirror remote");
699 assert_eq!(
700 render(&config),
701 "[remote \"origin\"]\n\tfetch = +refs/*:refs/*\n\tmirror = true\n"
702 );
703 }
704
705 #[test]
706 fn add_remote_rejects_duplicate() {
707 let mut config = config_from("[remote \"origin\"]\n\turl = a\n");
708 let err = add_remote_with_fetch(&mut config, "origin", "b", &[]).expect_err("duplicate");
709 assert_eq!(err, RemoteEditError::AlreadyExists);
710 }
711
712 #[test]
713 fn remove_remote_drops_section_and_back_references() {
714 let mut config = config_from(
715 "[remote \"origin\"]\n\turl = a\n\
716 [branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n\
717 [remote]\n\tpushDefault = origin\n",
718 );
719 remove_remote(&mut config, "origin").expect("remove");
720 assert_eq!(render(&config), "");
721 }
722
723 #[test]
724 fn remove_remote_drops_pushremote_references_but_keeps_other_remotes() {
725 let mut config = config_from(
726 "[remote \"origin\"]\n\turl = a\n\
727 [remote \"backup\"]\n\turl = b\n\
728 [branch \"main\"]\n\tremote = backup\n\tmerge = refs/heads/main\n\tpushRemote = origin\n\
729 [branch \"topic\"]\n\tpushRemote = backup\n\
730 [remote]\n\tpushDefault = backup\n",
731 );
732 remove_remote(&mut config, "origin").expect("remove");
733 assert_eq!(
734 render(&config),
735 "[remote \"backup\"]\n\turl = b\n\
736 [branch \"main\"]\n\tremote = backup\n\tmerge = refs/heads/main\n\
737 [branch \"topic\"]\n\tpushRemote = backup\n\
738 [remote]\n\tpushDefault = backup\n"
739 );
740 }
741
742 #[test]
743 fn remove_remote_keeps_unrelated_branch_keys() {
744 let mut config = config_from(
745 "[remote \"origin\"]\n\turl = a\n\
746 [branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n\trebase = true\n",
747 );
748 remove_remote(&mut config, "origin").expect("remove");
749 assert_eq!(render(&config), "[branch \"main\"]\n\trebase = true\n");
751 }
752
753 #[test]
754 fn remove_remote_reports_missing() {
755 let mut config = config_from("[remote \"origin\"]\n\turl = a\n");
756 let err = remove_remote(&mut config, "missing").expect_err("missing");
757 assert_eq!(err, RemoteEditError::NotFound);
758 }
759
760 #[test]
761 fn set_url_replaces_sole_url() {
762 let mut config = config_from("[remote \"origin\"]\n\turl = old\n");
763 set_url(
764 &mut config,
765 "origin",
766 SetUrlKind::Fetch,
767 SetUrlOp::Set { url: "new" },
768 )
769 .expect("set");
770 assert_eq!(config.get("remote", Some("origin"), "url"), Some("new"));
771 }
772
773 #[test]
774 fn set_url_edits_highest_precedence_remote_section() {
775 let mut config = config_from(
776 "[remote \"origin\"]\n\turl = old-low\n\
777 [remote \"origin\"]\n\turl = old-high\n",
778 );
779 set_url(
780 &mut config,
781 "origin",
782 SetUrlKind::Fetch,
783 SetUrlOp::Set { url: "new-high" },
784 )
785 .expect("set");
786 assert_eq!(
787 remote_config_values(&config, "origin", "url"),
788 vec!["old-low", "new-high"]
789 );
790 }
791
792 #[test]
793 fn set_url_inserts_when_absent() {
794 let mut config = config_from("[remote \"origin\"]\n\tfetch = spec\n");
795 set_url(
796 &mut config,
797 "origin",
798 SetUrlKind::Fetch,
799 SetUrlOp::Set { url: "new" },
800 )
801 .expect("set");
802 assert_eq!(
804 render(&config),
805 "[remote \"origin\"]\n\turl = new\n\tfetch = spec\n"
806 );
807 }
808
809 #[test]
810 fn set_url_add_appends_pushurl() {
811 let mut config = config_from("[remote \"origin\"]\n\turl = a\n");
812 set_url(
813 &mut config,
814 "origin",
815 SetUrlKind::Push,
816 SetUrlOp::Add { url: "p" },
817 )
818 .expect("add");
819 assert_eq!(config.get("remote", Some("origin"), "pushurl"), Some("p"));
820 }
821
822 #[test]
823 fn set_url_delete_refuses_to_empty_fetch_urls() {
824 let mut config = config_from("[remote \"origin\"]\n\turl = only\n");
825 let err = set_url(
826 &mut config,
827 "origin",
828 SetUrlKind::Fetch,
829 SetUrlOp::Delete {
830 matches: &|value| value == "only",
831 },
832 )
833 .expect_err("delete all");
834 assert_eq!(err, SetUrlError::DeleteAllFetchUrls);
835 assert_eq!(config.get("remote", Some("origin"), "url"), Some("only"));
837 }
838
839 #[test]
840 fn set_url_delete_removes_matching_push_urls() {
841 let mut config =
842 config_from("[remote \"origin\"]\n\turl = u\n\tpushurl = keep\n\tpushurl = drop\n");
843 set_url(
844 &mut config,
845 "origin",
846 SetUrlKind::Push,
847 SetUrlOp::Delete {
848 matches: &|value| value == "drop",
849 },
850 )
851 .expect("delete");
852 assert_eq!(
853 config.get_all("remote", Some("origin"), "pushurl"),
854 vec![Some("keep")]
855 );
856 }
857
858 #[test]
859 fn set_url_replace_requires_unique_match() {
860 let mut config = config_from("[remote \"origin\"]\n\turl = same\n\turl = same\n");
861 let err = set_url(
862 &mut config,
863 "origin",
864 SetUrlKind::Fetch,
865 SetUrlOp::Replace {
866 url: "new",
867 matches: &|value| value == "same",
868 },
869 )
870 .expect_err("ambiguous");
871 assert_eq!(err, SetUrlError::MultipleValues);
872 }
873
874 #[test]
875 fn set_url_replace_reports_no_match() {
876 let mut config = config_from("[remote \"origin\"]\n\turl = a\n");
877 let err = set_url(
878 &mut config,
879 "origin",
880 SetUrlKind::Fetch,
881 SetUrlOp::Replace {
882 url: "new",
883 matches: &|value| value == "absent",
884 },
885 )
886 .expect_err("no match");
887 assert_eq!(err, SetUrlError::NoMatch);
888 }
889
890 #[test]
891 fn set_url_on_missing_remote_errors() {
892 let mut config = GitConfig::default();
893 let err = set_url(
894 &mut config,
895 "origin",
896 SetUrlKind::Fetch,
897 SetUrlOp::Set { url: "x" },
898 )
899 .expect_err("missing remote");
900 assert_eq!(err, SetUrlError::RemoteNotFound);
901 }
902}