Skip to main content

sley_config/
remotes.rs

1//! Structured editing of `[remote "<name>"]` configuration.
2//!
3//! These helpers operate on an in-memory [`GitConfig`] document and implement
4//! the document-mutation half of `git remote add` / `git remote remove` /
5//! `git remote set-url`: they decide which `[remote "<name>"]` sections and
6//! `[branch]` back-references to add, drop, or rewrite. They deliberately do
7//! *not* read or write files, run argument parsing, or print anything — callers
8//! (e.g. the CLI) own I/O, argument handling, and the exact user-facing
9//! diagnostics, translating the structured [`crate::remotes::RemoteEditError`] /
10//! [`crate::remotes::SetUrlError`] outcomes returned here into their own messages and exit
11//! codes.
12//!
13//! Callers should persist edits with [`GitConfig::to_preserved_bytes`] when the
14//! config was loaded from a user file (comments and blank lines are kept).
15//! [`GitConfig::to_canonical_bytes`] remains for tests and green-field writes.
16
17use std::collections::BTreeSet;
18use std::path::Path;
19
20use crate::{ConfigEntry, ConfigSection, GitConfig};
21
22/// The default fetch refspec git writes for a freshly added remote:
23/// `+refs/heads/*:refs/remotes/<name>/*`.
24pub fn default_fetch_refspec(name: &str) -> String {
25    format!("+refs/heads/*:refs/remotes/{name}/*")
26}
27
28/// The configured remote names — the subsection of every `[remote "<name>"]`
29/// section — sorted alphabetically with duplicates collapsed.
30///
31/// This mirrors the order `git remote` lists remotes in (and the order the CLI
32/// has always used): names are de-duplicated and sorted, not returned in raw
33/// file order.
34pub 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
45/// All `remote.<name>.<key>` values, in config order.
46pub 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
61/// Rewrite `url` per the longest matching `url.<base>.insteadOf` (or
62/// `pushInsteadOf` when `push`) prefix, mirroring git's `insteadOf` resolution.
63pub 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 &section.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
105/// Resolve a fetch URL for `remote`, which may be a configured remote name or a
106/// literal URL/path. Uses `remote.<name>.url` when configured, then applies
107/// `url.*.insteadOf` rewriting from `config`.
108pub 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
116/// Resolve a push URL for `remote`, preferring `pushurl` over `url` when
117/// configured. An explicit `pushurl` is not rewritten by `pushInsteadOf`
118/// (matching git's `remote.c`); it still gets regular `insteadOf` rewriting.
119/// When falling back to `url` or a literal remote argument, `pushInsteadOf`
120/// participates in the rewrite.
121pub 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
135/// Whether a `[remote "<name>"]` section exists (subsection matched
136/// case-sensitively, as git treats subsection names).
137pub 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
144/// Whether `name` is a valid remote nickname, mirroring git's
145/// `valid_remote_nick`: non-empty, not `.`/`..`, and containing no path
146/// separators. Only such names trigger the legacy `remotes/`/`branches/` file
147/// lookup.
148fn valid_remote_nick(name: &str) -> bool {
149    !name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')
150}
151
152/// The repository default branch name used to fill the missing `#frag` of a
153/// `branches/<name>` file, mirroring git's `repo_default_branch_name`:
154/// `GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME` env, then `init.defaultBranch`, then
155/// the compiled-in default (`master`).
156fn 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
169/// Synthesize `[remote "<name>"]` sections from git's legacy file-based remote
170/// definitions in `$GIT_DIR/remotes/<name>` and `$GIT_DIR/branches/<name>`,
171/// appending them to `config`.
172///
173/// This mirrors git's `remote.c` lazy lookup order: a `remotes/`/`branches/`
174/// file is only consulted for a remote nickname that is *not* already a valid
175/// config remote (one with a `url`). For each such file, the parsed URL and
176/// refspecs are turned into the equivalent config entries so every downstream
177/// consumer (`resolve_remote_fetch_url`, the configured-fetch refspecs, push)
178/// sees a uniform `[remote "<name>"]` view.
179///
180/// `remotes/<name>` format (`read_remotes_file`):
181/// * `URL: <url>` → `url`
182/// * `Pull: <spec>` → `fetch`
183/// * `Push: <spec>` → `push`
184///
185/// `branches/<name>` format (`read_branches_file`): a single line `<url>` or
186/// `<url>#<branch>`. The fetch refspec is `refs/heads/<branch>:refs/heads/<name>`
187/// (the local branch matching the remote name), the push is `HEAD:refs/heads/
188/// <branch>`, and tags are always auto-followed.
189pub 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            // git sets remote->fetch_tags = 1 (always auto-follow tags). The
271            // tagopt config equivalent is `--tags`.
272            ConfigEntry::new("tagopt", Some("--tags".to_string())),
273        ];
274        out.push(ConfigSection::new("remote", Some(name), section_entries));
275    }
276}
277
278/// Failure modes shared by [`add_remote`] and [`remove_remote`].
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub enum RemoteEditError {
281    /// `add_remote` was asked to create a remote that already exists.
282    AlreadyExists,
283    /// `remove_remote` was asked to drop a remote that does not exist.
284    NotFound,
285}
286
287/// Append a `[remote "<name>"]` section built from `entries`, failing with
288/// [`RemoteEditError::AlreadyExists`] if the remote is already configured.
289///
290/// `entries` is the fully-formed body of the section (e.g. a `url` entry plus
291/// one or more `fetch` entries); building it from command-line options
292/// (`--mirror`, `--track`, `--tags`, …) is the caller's responsibility. For the
293/// common case use [`add_remote_with_fetch`].
294pub 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
310/// Append a `[remote "<name>"]` section with a `url` entry and the given fetch
311/// refspecs, failing with [`RemoteEditError::AlreadyExists`] if the remote is
312/// already configured.
313///
314/// When `fetch_refspecs` is empty the standard default
315/// (`+refs/heads/*:refs/remotes/<name>/*`, see [`default_fetch_refspec`]) is
316/// written, matching `git remote add <name> <url>`.
317pub 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
334/// Remove the `[remote "<name>"]` section and every back-reference to it, the
335/// way `git remote remove` does.
336///
337/// In addition to dropping the remote's own section this clears dependent
338/// configuration:
339/// * `[branch].remote = <name>` and the matching `[branch].merge` for branches
340///   that track this remote;
341/// * any `[branch].pushRemote = <name>`;
342/// * `[remote].pushDefault = <name>`.
343///
344/// `[branch]` and bare `[remote]` sections left empty by those removals are
345/// dropped. Returns [`RemoteEditError::NotFound`] (leaving `config` unchanged)
346/// when no `[remote "<name>"]` section exists.
347pub 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
359/// Strip configuration that references `remote` once its own section is gone:
360/// tracking branches (`branch.<x>.remote`/`merge`), push overrides
361/// (`branch.<x>.pushRemote`), and `remote.pushDefault`. Sections emptied by the
362/// removal are dropped.
363fn 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/// Which URL list a [`set_url`] call edits.
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum SetUrlKind {
392    /// Edit the fetch URLs (`remote.<name>.url`).
393    Fetch,
394    /// Edit the push URLs (`remote.<name>.pushurl`).
395    Push,
396}
397
398impl SetUrlKind {
399    /// The configuration key this kind edits (`url` or `pushurl`).
400    pub fn key(self) -> &'static str {
401        match self {
402            SetUrlKind::Fetch => "url",
403            SetUrlKind::Push => "pushurl",
404        }
405    }
406}
407
408/// The mutation [`set_url`] performs on the selected URL list.
409///
410/// `Delete` and `Replace` carry the URL matcher git applies (the CLI builds a
411/// `value-pattern` matcher; here it is any predicate over the stored URL
412/// string) so this crate stays free of a regex implementation.
413pub enum SetUrlOp<'a> {
414    /// `--add`: append `url` to the list.
415    Add { url: &'a str },
416    /// `--delete`: remove every URL matching the predicate.
417    Delete { matches: &'a dyn Fn(&str) -> bool },
418    /// `set-url <name> <newurl> <oldurl>`: replace the single URL matching the
419    /// predicate with `url`.
420    Replace {
421        url: &'a str,
422        matches: &'a dyn Fn(&str) -> bool,
423    },
424    /// `set-url <name> <newurl>`: set the sole URL (or insert one if none
425    /// exists) to `url`.
426    Set { url: &'a str },
427}
428
429/// Why a [`set_url`] call could not be applied.
430///
431/// Each variant corresponds to a distinct `git remote set-url` failure; the
432/// caller maps them to git's exact diagnostics and exit status. `config` is
433/// left unchanged when any of these is returned.
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub enum SetUrlError {
436    /// No `[remote "<name>"]` section exists.
437    RemoteNotFound,
438    /// `Replace` found no URL matching the old-URL pattern.
439    NoMatch,
440    /// `Delete` found no URL matching the pattern.
441    DeleteNoMatch,
442    /// `Delete` (on fetch URLs) would remove every non-push URL.
443    DeleteAllFetchUrls,
444    /// `Set`/`Replace` found multiple values where a single one was required.
445    MultipleValues,
446}
447
448/// Apply a URL edit to `remote.<name>`'s fetch or push URL list, mirroring
449/// `git remote set-url` (including its `--push`, `--add`, and `--delete`
450/// variants).
451///
452/// On success `config` is mutated in place and `Ok(())` is returned; on any
453/// [`SetUrlError`] the document is left untouched. The target section is the
454/// last `[remote "<name>"]` section in the document (git edits the
455/// highest-precedence one).
456pub 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        // The tracking keys go; the unrelated `rebase` key (and its section) stay.
750        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        // url is inserted before the existing fetch entry.
803        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        // Document untouched on error.
836        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}