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}