remote_output.rs

  1use anyhow::Context as _;
  2
  3use git::repository::{Remote, RemoteCommandOutput};
  4use linkify::{LinkFinder, LinkKind};
  5use ui::SharedString;
  6use util::ResultExt as _;
  7
  8#[derive(Clone)]
  9pub enum RemoteAction {
 10    Fetch(Option<Remote>),
 11    Pull(Remote),
 12    Push(SharedString, Remote),
 13}
 14
 15impl RemoteAction {
 16    pub fn name(&self) -> &'static str {
 17        match self {
 18            RemoteAction::Fetch(_) => "fetch",
 19            RemoteAction::Pull(_) => "pull",
 20            RemoteAction::Push(_, _) => "push",
 21        }
 22    }
 23}
 24
 25pub enum SuccessStyle {
 26    Toast,
 27    ToastWithLog { output: RemoteCommandOutput },
 28    PushPrLink { text: String, link: String },
 29}
 30
 31pub struct SuccessMessage {
 32    pub message: String,
 33    pub style: SuccessStyle,
 34}
 35
 36pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> SuccessMessage {
 37    match action {
 38        RemoteAction::Fetch(remote) => {
 39            if output.stderr.is_empty() {
 40                SuccessMessage {
 41                    message: "Fetch: Already up to date".into(),
 42                    style: SuccessStyle::Toast,
 43                }
 44            } else {
 45                let message = match remote {
 46                    Some(remote) => format!("Synchronized with {}", remote.name),
 47                    None => "Synchronized with remotes".into(),
 48                };
 49                SuccessMessage {
 50                    message,
 51                    style: SuccessStyle::ToastWithLog { output },
 52                }
 53            }
 54        }
 55        RemoteAction::Pull(remote_ref) => {
 56            let get_changes = |output: &RemoteCommandOutput| -> anyhow::Result<u32> {
 57                let last_line = output
 58                    .stdout
 59                    .lines()
 60                    .last()
 61                    .context("Failed to get last line of output")?
 62                    .trim();
 63
 64                let files_changed = last_line
 65                    .split_whitespace()
 66                    .next()
 67                    .context("Failed to get first word of last line")?
 68                    .parse()?;
 69
 70                Ok(files_changed)
 71            };
 72            if output.stdout.ends_with("Already up to date.\n") {
 73                SuccessMessage {
 74                    message: "Pull: Already up to date".into(),
 75                    style: SuccessStyle::Toast,
 76                }
 77            } else if output.stdout.starts_with("Updating") {
 78                let files_changed = get_changes(&output).log_err();
 79                let message = if let Some(files_changed) = files_changed {
 80                    format!(
 81                        "Received {} file change{} from {}",
 82                        files_changed,
 83                        if files_changed == 1 { "" } else { "s" },
 84                        remote_ref.name
 85                    )
 86                } else {
 87                    format!("Fast forwarded from {}", remote_ref.name)
 88                };
 89                SuccessMessage {
 90                    message,
 91                    style: SuccessStyle::ToastWithLog { output },
 92                }
 93            } else if output.stdout.starts_with("Merge") {
 94                let files_changed = get_changes(&output).log_err();
 95                let message = if let Some(files_changed) = files_changed {
 96                    format!(
 97                        "Merged {} file change{} from {}",
 98                        files_changed,
 99                        if files_changed == 1 { "" } else { "s" },
100                        remote_ref.name
101                    )
102                } else {
103                    format!("Merged from {}", remote_ref.name)
104                };
105                SuccessMessage {
106                    message,
107                    style: SuccessStyle::ToastWithLog { output },
108                }
109            } else if output.stdout.contains("Successfully rebased") {
110                SuccessMessage {
111                    message: format!("Successfully rebased from {}", remote_ref.name),
112                    style: SuccessStyle::ToastWithLog { output },
113                }
114            } else {
115                SuccessMessage {
116                    message: format!("Successfully pulled from {}", remote_ref.name),
117                    style: SuccessStyle::ToastWithLog { output },
118                }
119            }
120        }
121        RemoteAction::Push(branch_name, remote_ref) => {
122            let message = if output.stderr.ends_with("Everything up-to-date\n") {
123                "Push: Everything is up-to-date".to_string()
124            } else {
125                format!("Pushed {} to {}", branch_name, remote_ref.name)
126            };
127
128            let style = if output.stderr.ends_with("Everything up-to-date\n") {
129                Some(SuccessStyle::Toast)
130            } else if output.stderr.contains("\nremote: ") {
131                let pr_hints = [
132                    ("Create a pull request", "Create Pull Request"), // GitHub
133                    ("Create pull request", "Create Pull Request"),   // Bitbucket
134                    ("create a merge request", "Create Merge Request"), // GitLab
135                    ("View merge request", "View Merge Request"),     // GitLab
136                ];
137                pr_hints
138                    .iter()
139                    .find(|(indicator, _)| output.stderr.contains(indicator))
140                    .and_then(|(_, mapped)| {
141                        let finder = LinkFinder::new();
142
143                        output
144                            .stderr
145                            .lines()
146                            .filter(|line| line.trim_start().starts_with("remote:"))
147                            .find_map(|line| {
148                                finder
149                                    .links(line)
150                                    .find(|link| *link.kind() == LinkKind::Url)
151                                    .map(|link| SuccessStyle::PushPrLink {
152                                        text: mapped.to_string(),
153                                        link: link.as_str().to_string(),
154                                    })
155                            })
156                    })
157            } else {
158                None
159            };
160            SuccessMessage {
161                message,
162                style: style.unwrap_or(SuccessStyle::ToastWithLog { output }),
163            }
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use indoc::indoc;
172
173    #[test]
174    fn test_push_new_branch_pull_request() {
175        let action = RemoteAction::Push(
176            SharedString::new_static("test_branch"),
177            Remote {
178                name: SharedString::new_static("test_remote"),
179            },
180        );
181
182        let output = RemoteCommandOutput {
183            stdout: String::new(),
184            stderr: indoc! { "
185                Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
186                remote:
187                remote: Create a pull request for 'test' on GitHub by visiting:
188                remote:      https://example.com/test/test/pull/new/test
189                remote:
190                To example.com:test/test.git
191                 * [new branch]      test -> test
192                "}
193            .to_string(),
194        };
195
196        let msg = format_output(&action, output);
197
198        if let SuccessStyle::PushPrLink { text: hint, link } = &msg.style {
199            assert_eq!(hint, "Create Pull Request");
200            assert_eq!(link, "https://example.com/test/test/pull/new/test");
201        } else {
202            panic!("Expected PushPrLink variant");
203        }
204    }
205
206    #[test]
207    fn test_push_new_branch_merge_request() {
208        let action = RemoteAction::Push(
209            SharedString::new_static("test_branch"),
210            Remote {
211                name: SharedString::new_static("test_remote"),
212            },
213        );
214
215        let output = RemoteCommandOutput {
216            stdout: String::new(),
217            stderr: indoc! {"
218                Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
219                remote:
220                remote: To create a merge request for test, visit:
221                remote:   https://example.com/test/test/-/merge_requests/new?merge_request%5Bsource_branch%5D=test
222                remote:
223                To example.com:test/test.git
224                 * [new branch]      test -> test
225                "}
226            .to_string()
227            };
228
229        let msg = format_output(&action, output);
230
231        if let SuccessStyle::PushPrLink { text, link } = &msg.style {
232            assert_eq!(text, "Create Merge Request");
233            assert_eq!(
234                link,
235                "https://example.com/test/test/-/merge_requests/new?merge_request%5Bsource_branch%5D=test"
236            );
237        } else {
238            panic!("Expected PushPrLink variant");
239        }
240    }
241
242    #[test]
243    fn test_push_branch_existing_merge_request() {
244        let action = RemoteAction::Push(
245            SharedString::new_static("test_branch"),
246            Remote {
247                name: SharedString::new_static("test_remote"),
248            },
249        );
250
251        let output = RemoteCommandOutput {
252            stdout: String::new(),
253            // Simulate an extraneous link that should not be found in top 3 lines
254            stderr: indoc! {"
255                ** WARNING: connection is not using a post-quantum key exchange algorithm.
256                ** This session may be vulnerable to \"store now, decrypt later\" attacks.
257                ** The server may need to be upgraded. See https://openssh.com/pq.html
258                Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
259                remote:
260                remote: View merge request for test:
261                remote:    https://example.com/test/test/-/merge_requests/99999
262                remote:
263                To example.com:test/test.git
264                    + 80bd3c83be...e03d499d2e test -> test
265                "}
266            .to_string(),
267        };
268
269        let msg = format_output(&action, output);
270
271        if let SuccessStyle::PushPrLink { text, link } = &msg.style {
272            assert_eq!(text, "View Merge Request");
273            assert_eq!(link, "https://example.com/test/test/-/merge_requests/99999");
274        } else {
275            panic!("Expected PushPrLink variant");
276        }
277    }
278
279    #[test]
280    fn test_push_new_branch_no_link() {
281        let action = RemoteAction::Push(
282            SharedString::new_static("test_branch"),
283            Remote {
284                name: SharedString::new_static("test_remote"),
285            },
286        );
287
288        let output = RemoteCommandOutput {
289            stdout: String::new(),
290            stderr: indoc! { "
291                To http://example.com/test/test.git
292                 * [new branch]      test -> test
293                ",
294            }
295            .to_string(),
296        };
297
298        let msg = format_output(&action, output);
299
300        if let SuccessStyle::ToastWithLog { output } = &msg.style {
301            assert_eq!(
302                output.stderr,
303                "To http://example.com/test/test.git\n * [new branch]      test -> test\n"
304            );
305        } else {
306            panic!("Expected ToastWithLog variant");
307        }
308    }
309}