ZIm/crates/git_ui/src/remote_output.rs
Guillaume Launay 182edbf526
git_panel: Improve toast messages for push/pull/fetch (#35092)
On GitLab, when pushing a branch and a MR already existing the remote
log contains "View merge request" and the link to the MR.

Fixed `Already up to date` stdout check on pull (was `Everything up to
date` on stderr)
Fixed `Everything up-to-date` check on push (was `Everything up to
date`)
Improved messaging for up-to-date for fetch/push/pull
Fixed tests introduced in
https://github.com/zed-industries/zed/pull/33833.

<img width="470" height="111" alt="Screenshot 2025-07-31 at 18 37 05"
src="https://github.com/user-attachments/assets/2a5dcc4c-6f53-4a85-b983-8e25149efcc0"
/>

Release Notes:

- Git UI: Add "View Pull Request" when pushing to Gitlab remotes
- git: Improved toast messages on fetch/push/pull

---------

Co-authored-by: Peter Tripp <peter@zed.dev>
2025-08-04 18:20:20 -04:00

299 lines
10 KiB
Rust

use anyhow::Context as _;
use git::repository::{Remote, RemoteCommandOutput};
use linkify::{LinkFinder, LinkKind};
use ui::SharedString;
use util::ResultExt as _;
#[derive(Clone)]
pub enum RemoteAction {
Fetch(Option<Remote>),
Pull(Remote),
Push(SharedString, Remote),
}
impl RemoteAction {
pub fn name(&self) -> &'static str {
match self {
RemoteAction::Fetch(_) => "fetch",
RemoteAction::Pull(_) => "pull",
RemoteAction::Push(_, _) => "push",
}
}
}
pub enum SuccessStyle {
Toast,
ToastWithLog { output: RemoteCommandOutput },
PushPrLink { text: String, link: String },
}
pub struct SuccessMessage {
pub message: String,
pub style: SuccessStyle,
}
pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> SuccessMessage {
match action {
RemoteAction::Fetch(remote) => {
if output.stderr.is_empty() {
SuccessMessage {
message: "Fetch: Already up to date".into(),
style: SuccessStyle::Toast,
}
} else {
let message = match remote {
Some(remote) => format!("Synchronized with {}", remote.name),
None => "Synchronized with remotes".into(),
};
SuccessMessage {
message,
style: SuccessStyle::ToastWithLog { output },
}
}
}
RemoteAction::Pull(remote_ref) => {
let get_changes = |output: &RemoteCommandOutput| -> anyhow::Result<u32> {
let last_line = output
.stdout
.lines()
.last()
.context("Failed to get last line of output")?
.trim();
let files_changed = last_line
.split_whitespace()
.next()
.context("Failed to get first word of last line")?
.parse()?;
Ok(files_changed)
};
if output.stdout.ends_with("Already up to date.\n") {
SuccessMessage {
message: "Pull: Already up to date".into(),
style: SuccessStyle::Toast,
}
} else if output.stdout.starts_with("Updating") {
let files_changed = get_changes(&output).log_err();
let message = if let Some(files_changed) = files_changed {
format!(
"Received {} file change{} from {}",
files_changed,
if files_changed == 1 { "" } else { "s" },
remote_ref.name
)
} else {
format!("Fast forwarded from {}", remote_ref.name)
};
SuccessMessage {
message,
style: SuccessStyle::ToastWithLog { output },
}
} else if output.stdout.starts_with("Merge") {
let files_changed = get_changes(&output).log_err();
let message = if let Some(files_changed) = files_changed {
format!(
"Merged {} file change{} from {}",
files_changed,
if files_changed == 1 { "" } else { "s" },
remote_ref.name
)
} else {
format!("Merged from {}", remote_ref.name)
};
SuccessMessage {
message,
style: SuccessStyle::ToastWithLog { output },
}
} else if output.stdout.contains("Successfully rebased") {
SuccessMessage {
message: format!("Successfully rebased from {}", remote_ref.name),
style: SuccessStyle::ToastWithLog { output },
}
} else {
SuccessMessage {
message: format!("Successfully pulled from {}", remote_ref.name),
style: SuccessStyle::ToastWithLog { output },
}
}
}
RemoteAction::Push(branch_name, remote_ref) => {
let message = if output.stderr.ends_with("Everything up-to-date\n") {
"Push: Everything is up-to-date".to_string()
} else {
format!("Pushed {} to {}", branch_name, remote_ref.name)
};
let style = if output.stderr.ends_with("Everything up-to-date\n") {
Some(SuccessStyle::Toast)
} else if output.stderr.contains("\nremote: ") {
let pr_hints = [
("Create a pull request", "Create Pull Request"), // GitHub
("Create pull request", "Create Pull Request"), // Bitbucket
("create a merge request", "Create Merge Request"), // GitLab
("View merge request", "View Merge Request"), // GitLab
];
pr_hints
.iter()
.find(|(indicator, _)| output.stderr.contains(indicator))
.and_then(|(_, mapped)| {
let finder = LinkFinder::new();
finder
.links(&output.stderr)
.filter(|link| *link.kind() == LinkKind::Url)
.map(|link| link.start()..link.end())
.next()
.map(|link| SuccessStyle::PushPrLink {
text: mapped.to_string(),
link: output.stderr[link].to_string(),
})
})
} else {
None
};
SuccessMessage {
message,
style: style.unwrap_or(SuccessStyle::ToastWithLog { output }),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;
#[test]
fn test_push_new_branch_pull_request() {
let action = RemoteAction::Push(
SharedString::new("test_branch"),
Remote {
name: SharedString::new("test_remote"),
},
);
let output = RemoteCommandOutput {
stdout: String::new(),
stderr: indoc! { "
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote:
remote: Create a pull request for 'test' on GitHub by visiting:
remote: https://example.com/test/test/pull/new/test
remote:
To example.com:test/test.git
* [new branch] test -> test
"}
.to_string(),
};
let msg = format_output(&action, output);
if let SuccessStyle::PushPrLink { text: hint, link } = &msg.style {
assert_eq!(hint, "Create Pull Request");
assert_eq!(link, "https://example.com/test/test/pull/new/test");
} else {
panic!("Expected PushPrLink variant");
}
}
#[test]
fn test_push_new_branch_merge_request() {
let action = RemoteAction::Push(
SharedString::new("test_branch"),
Remote {
name: SharedString::new("test_remote"),
},
);
let output = RemoteCommandOutput {
stdout: String::new(),
stderr: indoc! {"
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote:
remote: To create a merge request for test, visit:
remote: https://example.com/test/test/-/merge_requests/new?merge_request%5Bsource_branch%5D=test
remote:
To example.com:test/test.git
* [new branch] test -> test
"}
.to_string()
};
let msg = format_output(&action, output);
if let SuccessStyle::PushPrLink { text, link } = &msg.style {
assert_eq!(text, "Create Merge Request");
assert_eq!(
link,
"https://example.com/test/test/-/merge_requests/new?merge_request%5Bsource_branch%5D=test"
);
} else {
panic!("Expected PushPrLink variant");
}
}
#[test]
fn test_push_branch_existing_merge_request() {
let action = RemoteAction::Push(
SharedString::new("test_branch"),
Remote {
name: SharedString::new("test_remote"),
},
);
let output = RemoteCommandOutput {
stdout: String::new(),
stderr: indoc! {"
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote:
remote: View merge request for test:
remote: https://example.com/test/test/-/merge_requests/99999
remote:
To example.com:test/test.git
+ 80bd3c83be...e03d499d2e test -> test
"}
.to_string(),
};
let msg = format_output(&action, output);
if let SuccessStyle::PushPrLink { text, link } = &msg.style {
assert_eq!(text, "View Merge Request");
assert_eq!(link, "https://example.com/test/test/-/merge_requests/99999");
} else {
panic!("Expected PushPrLink variant");
}
}
#[test]
fn test_push_new_branch_no_link() {
let action = RemoteAction::Push(
SharedString::new("test_branch"),
Remote {
name: SharedString::new("test_remote"),
},
);
let output = RemoteCommandOutput {
stdout: String::new(),
stderr: indoc! { "
To http://example.com/test/test.git
* [new branch] test -> test
",
}
.to_string(),
};
let msg = format_output(&action, output);
if let SuccessStyle::ToastWithLog { output } = &msg.style {
assert_eq!(
output.stderr,
"To http://example.com/test/test.git\n * [new branch] test -> test\n"
);
} else {
panic!("Expected ToastWithLog variant");
}
}
}