Skip to main content

lattice_vcs/
remote.rs

1use crate::{Repository, Result, VcsError};
2
3/// Remote management — list, add, rename, remove, set-url, prune.
4///
5/// The peer of [`crate::Branch`] and [`crate::Stash`]: a thin, typed
6/// wrapper over the git CLI. Fetch / pull / push are *not* here — those
7/// are long-running network operations owned by `lattice-magit`'s
8/// `RemoteOp`, which runs them detached. Everything on this type is a
9/// local config edit that returns immediately.
10pub struct Remote;
11
12/// A single configured remote.
13///
14/// `push_url` falls back to `fetch_url` when the remote has no separate
15/// `pushurl` configured — which is the common case, and matches what
16/// `git remote -v` prints.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct RemoteEntry {
19    /// The remote's name, e.g. `origin`.
20    pub name: String,
21    /// The URL fetches read from.
22    pub fetch_url: String,
23    /// The URL pushes write to.
24    pub push_url: String,
25}
26
27/// Parse the output of `git remote -v`.
28///
29/// The format is one `<name>\t<url> (fetch)` line and one
30/// `<name>\t<url> (push)` line per remote, fetch first. Remotes are
31/// returned in first-appearance order, which is the alphabetical order
32/// git itself emits.
33///
34/// **Unparseable lines are skipped, not fatal.** A remote whose name or
35/// URL contains something this parser does not expect must not take the
36/// whole list down with it — the user would see an empty buffer with no
37/// way to reach the remotes that *are* fine. A line missing its tab, or
38/// carrying neither `(fetch)` nor `(push)`, is dropped silently here;
39/// the caller renders whatever survived.
40pub fn parse_remote_v(out: &str) -> Vec<RemoteEntry> {
41    let mut entries: Vec<RemoteEntry> = Vec::new();
42    for line in out.lines() {
43        let line = line.trim_end();
44        if line.is_empty() {
45            continue;
46        }
47        let Some((name, rest)) = line.split_once('\t') else {
48            continue;
49        };
50        let (url, kind) = match rest.rsplit_once(' ') {
51            Some((url, kind)) => (url.trim(), kind.trim()),
52            None => continue,
53        };
54        let is_push = match kind {
55            "(push)" => true,
56            "(fetch)" => false,
57            _ => continue,
58        };
59        match entries.iter_mut().find(|e| e.name == name) {
60            Some(existing) => {
61                if is_push {
62                    existing.push_url = url.to_string();
63                } else {
64                    existing.fetch_url = url.to_string();
65                }
66            }
67            None => entries.push(RemoteEntry {
68                name: name.to_string(),
69                // Both slots start at this URL so a remote that prints
70                // only one of the two lines still renders a sane pair.
71                fetch_url: url.to_string(),
72                push_url: url.to_string(),
73            }),
74        }
75    }
76    entries
77}
78
79impl Remote {
80    /// List every configured remote with its fetch and push URLs.
81    ///
82    /// Equivalent to `git remote -v`.
83    pub fn list(repo: &Repository) -> Result<Vec<RemoteEntry>> {
84        let out = repo
85            .run_git_str(["remote", "-v"])
86            .map_err(|e| VcsError::Remote(format!("remote list: {}", e)))?;
87        Ok(parse_remote_v(&out))
88    }
89
90    /// Add a new remote.
91    ///
92    /// Equivalent to `git remote add <name> <url>`. Does not fetch.
93    pub fn add(repo: &Repository, name: &str, url: &str) -> Result<()> {
94        repo.run_git(["remote", "add", name, url])
95            .map(|_| ())
96            .map_err(|e| VcsError::Remote(format!("remote add {}: {}", name, e)))
97    }
98
99    /// Rename a remote, rewriting its tracking refspecs.
100    ///
101    /// Equivalent to `git remote rename <old> <new>`.
102    pub fn rename(repo: &Repository, old: &str, new: &str) -> Result<()> {
103        repo.run_git(["remote", "rename", old, new])
104            .map(|_| ())
105            .map_err(|e| VcsError::Remote(format!("remote rename {} -> {}: {}", old, new, e)))
106    }
107
108    /// Remove a remote and its remote-tracking branches.
109    ///
110    /// Equivalent to `git remote remove <name>`.
111    pub fn remove(repo: &Repository, name: &str) -> Result<()> {
112        repo.run_git(["remote", "remove", name])
113            .map(|_| ())
114            .map_err(|e| VcsError::Remote(format!("remote remove {}: {}", name, e)))
115    }
116
117    /// Point a remote at a different URL.
118    ///
119    /// Equivalent to `git remote set-url <name> <url>`. This sets the
120    /// fetch URL; a remote with a separately-configured `pushurl` keeps
121    /// it, which is why [`RemoteEntry`] carries both.
122    pub fn set_url(repo: &Repository, name: &str, url: &str) -> Result<()> {
123        repo.run_git(["remote", "set-url", name, url])
124            .map(|_| ())
125            .map_err(|e| VcsError::Remote(format!("remote set-url {}: {}", name, e)))
126    }
127
128    /// Delete local refs for branches that no longer exist on the remote.
129    ///
130    /// Equivalent to `git remote prune <name>`. This talks to the
131    /// network, so callers run it off the actor thread like the other
132    /// remote operations.
133    pub fn prune(repo: &Repository, name: &str) -> Result<()> {
134        repo.run_git(["remote", "prune", name])
135            .map(|_| ())
136            .map_err(|e| VcsError::Remote(format!("remote prune {}: {}", name, e)))
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn parses_the_ordinary_two_line_per_remote_shape() {
146        let out = "origin\tgit@github.com:a/b.git (fetch)\n\
147                   origin\tgit@github.com:a/b.git (push)\n\
148                   upstream\thttps://example.com/c.git (fetch)\n\
149                   upstream\thttps://example.com/c.git (push)\n";
150        assert_eq!(
151            parse_remote_v(out),
152            vec![
153                RemoteEntry {
154                    name: "origin".into(),
155                    fetch_url: "git@github.com:a/b.git".into(),
156                    push_url: "git@github.com:a/b.git".into(),
157                },
158                RemoteEntry {
159                    name: "upstream".into(),
160                    fetch_url: "https://example.com/c.git".into(),
161                    push_url: "https://example.com/c.git".into(),
162                },
163            ]
164        );
165    }
166
167    #[test]
168    fn a_separate_pushurl_shows_as_a_differing_push_url() {
169        let out = "origin\thttps://example.com/read.git (fetch)\n\
170                   origin\tgit@example.com:write.git (push)\n";
171        let got = parse_remote_v(out);
172        assert_eq!(got.len(), 1);
173        assert_eq!(got[0].fetch_url, "https://example.com/read.git");
174        assert_eq!(got[0].push_url, "git@example.com:write.git");
175    }
176
177    #[test]
178    fn a_remote_printing_only_one_line_still_gets_both_urls() {
179        // Defensive: git always prints the pair, but a truncated read
180        // must not produce an entry with an empty URL column.
181        let out = "origin\tgit@github.com:a/b.git (fetch)\n";
182        let got = parse_remote_v(out);
183        assert_eq!(got[0].fetch_url, got[0].push_url);
184        assert!(!got[0].push_url.is_empty());
185    }
186
187    #[test]
188    fn unparseable_lines_are_skipped_and_do_not_lose_the_good_ones() {
189        let out = "garbage with no tab\n\
190                   origin\tgit@github.com:a/b.git (fetch)\n\
191                   weird\tsomething (mirror)\n\
192                   origin\tgit@github.com:a/b.git (push)\n";
193        let got = parse_remote_v(out);
194        assert_eq!(got.len(), 1, "only origin is well-formed: {:?}", got);
195        assert_eq!(got[0].name, "origin");
196    }
197
198    #[test]
199    fn no_remotes_is_an_empty_list_not_an_error() {
200        assert!(parse_remote_v("").is_empty());
201        assert!(parse_remote_v("\n\n").is_empty());
202    }
203
204    #[test]
205    fn a_url_containing_spaces_keeps_its_whole_url() {
206        // `rsplit_once(' ')` splits on the LAST space, so only the
207        // `(fetch)` / `(push)` suffix is peeled off.
208        let out = "local\t/tmp/my repo/.git (fetch)\n";
209        assert_eq!(parse_remote_v(out)[0].fetch_url, "/tmp/my repo/.git");
210    }
211}