From 06a623e99cc4360841dcd577d61a1bb9d733ca5b Mon Sep 17 00:00:00 2001 From: Tommy Han Date: Tue, 6 Jan 2026 00:15:21 +0800 Subject: [PATCH] git_ui: Add `CreatePullRequest` action (#42959) Closes #42217 Release Notes: - Added/Fixed/Improved ... added "Create Pull Request" Command when searching on the command Palette For more details, please refer to the issue, thank you. --------- Co-authored-by: dino --- crates/git/src/hosting_provider.rs | 9 +++ crates/git/src/repository.rs | 46 +++++++++++++ .../src/providers/github.rs | 62 +++++++++++++++++ .../src/providers/gitlab.rs | 68 ++++++++++++++++++ crates/git_ui/src/git_panel.rs | 69 ++++++++++++++++++- crates/git_ui/src/git_ui.rs | 12 +++- crates/zed_actions/src/lib.rs | 4 +- 7 files changed, 267 insertions(+), 3 deletions(-) diff --git a/crates/git/src/hosting_provider.rs b/crates/git/src/hosting_provider.rs index 225d4a3e2354fbd11e11b617ecdd9cb4202a63fe..6c69d7a714a69289a858f51837da35abebbb4a4a 100644 --- a/crates/git/src/hosting_provider.rs +++ b/crates/git/src/hosting_provider.rs @@ -92,6 +92,15 @@ pub trait GitHostingProvider { /// Returns a permalink to a file and/or selection on this hosting provider. fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url; + /// Returns a URL to create a pull request on this hosting provider. + fn build_create_pull_request_url( + &self, + _remote: &ParsedGitRemote, + _source_branch: &str, + ) -> Option { + None + } + /// Returns whether this provider supports avatars. fn supports_avatars(&self) -> bool; diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index c3dd0995ff83d4bfdd494e4b5c192ff5999c21f8..17cdbcea27313e911136357ffcde0ca56c19ce67 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -144,6 +144,12 @@ impl Upstream { pub fn stripped_ref_name(&self) -> Option<&str> { self.ref_name.strip_prefix("refs/remotes/") } + + pub fn branch_name(&self) -> Option<&str> { + self.ref_name + .strip_prefix("refs/remotes/") + .and_then(|stripped| stripped.split_once('/').map(|(_, name)| name)) + } } #[derive(Clone, Copy, Default)] @@ -3127,6 +3133,46 @@ mod tests { ) } + #[test] + fn test_upstream_branch_name() { + let upstream = Upstream { + ref_name: "refs/remotes/origin/feature/branch".into(), + tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { + ahead: 0, + behind: 0, + }), + }; + assert_eq!(upstream.branch_name(), Some("feature/branch")); + + let upstream = Upstream { + ref_name: "refs/remotes/upstream/main".into(), + tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { + ahead: 0, + behind: 0, + }), + }; + assert_eq!(upstream.branch_name(), Some("main")); + + let upstream = Upstream { + ref_name: "refs/heads/local".into(), + tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { + ahead: 0, + behind: 0, + }), + }; + assert_eq!(upstream.branch_name(), None); + + // Test case where upstream branch name differs from what might be the local branch name + let upstream = Upstream { + ref_name: "refs/remotes/origin/feature/git-pull-request".into(), + tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { + ahead: 0, + behind: 0, + }), + }; + assert_eq!(upstream.branch_name(), Some("feature/git-pull-request")); + } + impl RealGitRepository { /// Force a Git garbage collection on the repository. fn gc(&self) -> BoxFuture<'_, Result<()>> { diff --git a/crates/git_hosting_providers/src/providers/github.rs b/crates/git_hosting_providers/src/providers/github.rs index 4f5c71830da4e5ce4112812d0737ebc878df7b76..77e297d52bc6e9b0c669e32d591b9e77fcb17aa4 100644 --- a/crates/git_hosting_providers/src/providers/github.rs +++ b/crates/git_hosting_providers/src/providers/github.rs @@ -9,6 +9,7 @@ use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request}; use regex::Regex; use serde::Deserialize; use url::Url; +use urlencoding::encode; use git::{ BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote, @@ -224,6 +225,19 @@ impl GitHostingProvider for Github { permalink } + fn build_create_pull_request_url( + &self, + remote: &ParsedGitRemote, + source_branch: &str, + ) -> Option { + let ParsedGitRemote { owner, repo } = remote; + let encoded_source = encode(source_branch); + + self.base_url() + .join(&format!("{owner}/{repo}/pull/new/{encoded_source}")) + .ok() + } + fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option { let line = message.lines().next()?; let capture = pull_request_number_regex().captures(line)?; @@ -466,6 +480,25 @@ mod tests { assert_eq!(permalink.to_string(), expected_url.to_string()) } + #[test] + fn test_build_github_create_pr_url() { + let remote = ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }; + + let provider = Github::public_instance(); + + let url = provider + .build_create_pull_request_url(&remote, "feature/something cool") + .expect("url should be constructed"); + + assert_eq!( + url.as_str(), + "https://github.com/zed-industries/zed/pull/new/feature%2Fsomething%20cool" + ); + } + #[test] fn test_github_pull_requests() { let remote = ParsedGitRemote { @@ -526,4 +559,33 @@ mod tests { let expected_url = "https://github.com/zed-industries/nonexistent/blob/3ef1539900037dd3601be7149b2b39ed6d0ce3db/app/blog/%5Bslug%5D/page.tsx#L8"; assert_eq!(permalink.to_string(), expected_url.to_string()) } + + #[test] + fn test_build_create_pull_request_url() { + let remote = ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }; + + let github = Github::public_instance(); + let url = github + .build_create_pull_request_url(&remote, "feature/new-feature") + .unwrap(); + + assert_eq!( + url.as_str(), + "https://github.com/zed-industries/zed/pull/new/feature%2Fnew-feature" + ); + + let base_url = Url::parse("https://github.zed.com").unwrap(); + let github = Github::new("GitHub Self-Hosted", base_url); + let url = github + .build_create_pull_request_url(&remote, "feature/new-feature") + .expect("should be able to build pull request url"); + + assert_eq!( + url.as_str(), + "https://github.zed.com/zed-industries/zed/pull/new/feature%2Fnew-feature" + ); + } } diff --git a/crates/git_hosting_providers/src/providers/gitlab.rs b/crates/git_hosting_providers/src/providers/gitlab.rs index af3bb17494a79056db0fd4c531f67b77a31e0954..80171e2d3cbb092876e7c1e2d23b66cef839943c 100644 --- a/crates/git_hosting_providers/src/providers/gitlab.rs +++ b/crates/git_hosting_providers/src/providers/gitlab.rs @@ -7,6 +7,7 @@ use gpui::SharedString; use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request}; use serde::Deserialize; use url::Url; +use urlencoding::encode; use git::{ BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote, @@ -209,6 +210,25 @@ impl GitHostingProvider for Gitlab { permalink } + fn build_create_pull_request_url( + &self, + remote: &ParsedGitRemote, + source_branch: &str, + ) -> Option { + let mut url = self + .base_url() + .join(&format!( + "{}/{}/-/merge_requests/new", + remote.owner, remote.repo + )) + .ok()?; + + let query = format!("merge_request%5Bsource_branch%5D={}", encode(source_branch)); + + url.set_query(Some(&query)); + Some(url) + } + async fn commit_author_avatar_url( &self, repo_owner: &str, @@ -376,6 +396,25 @@ mod tests { assert_eq!(permalink.to_string(), expected_url.to_string()) } + #[test] + fn test_build_gitlab_create_pr_url() { + let remote = ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }; + + let provider = Gitlab::public_instance(); + + let url = provider + .build_create_pull_request_url(&remote, "feature/cool stuff") + .expect("create PR url should be constructed"); + + assert_eq!( + url.as_str(), + "https://gitlab.com/zed-industries/zed/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fcool%20stuff" + ); + } + #[test] fn test_build_gitlab_self_hosted_permalink_from_ssh_url() { let gitlab = @@ -417,4 +456,33 @@ mod tests { let expected_url = "https://gitlab-instance.big-co.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs"; assert_eq!(permalink.to_string(), expected_url.to_string()) } + + #[test] + fn test_build_create_pull_request_url() { + let remote = ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }; + + let github = Gitlab::public_instance(); + let url = github + .build_create_pull_request_url(&remote, "feature/new-feature") + .unwrap(); + + assert_eq!( + url.as_str(), + "https://gitlab.com/zed-industries/zed/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fnew-feature" + ); + + let base_url = Url::parse("https://gitlab.zed.com").unwrap(); + let github = Gitlab::new("GitLab Self-Hosted", base_url); + let url = github + .build_create_pull_request_url(&remote, "feature/new-feature") + .expect("should be able to build pull request url"); + + assert_eq!( + url.as_str(), + "https://gitlab.zed.com/zed-industries/zed/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fnew-feature" + ); + } } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index c016562866fb0ad89987e043160dee937830838a..b832bfbd4b7b6d84ef62688339dba574938bf305 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3076,6 +3076,68 @@ impl GitPanel { .detach_and_log_err(cx); } + pub fn create_pull_request(&self, window: &mut Window, cx: &mut Context) { + let result = (|| -> anyhow::Result<()> { + let repo = self + .active_repository + .clone() + .ok_or_else(|| anyhow::anyhow!("No active repository"))?; + + let (branch, remote_origin, remote_upstream) = { + let repository = repo.read(cx); + ( + repository.branch.clone(), + repository.remote_origin_url.clone(), + repository.remote_upstream_url.clone(), + ) + }; + + let branch = branch.ok_or_else(|| anyhow::anyhow!("No active branch"))?; + let source_branch = branch + .upstream + .as_ref() + .filter(|upstream| matches!(upstream.tracking, UpstreamTracking::Tracked(_))) + .and_then(|upstream| upstream.branch_name()) + .ok_or_else(|| anyhow::anyhow!("No remote configured for repository"))?; + let source_branch = source_branch.to_string(); + + let remote_url = branch + .upstream + .as_ref() + .and_then(|upstream| match upstream.remote_name() { + Some("upstream") => remote_upstream.as_deref(), + Some(_) => remote_origin.as_deref(), + None => None, + }) + .or(remote_origin.as_deref()) + .or(remote_upstream.as_deref()) + .ok_or_else(|| anyhow::anyhow!("No remote configured for repository"))?; + let remote_url = remote_url.to_string(); + + let provider_registry = GitHostingProviderRegistry::global(cx); + let Some((provider, parsed_remote)) = + git::parse_git_remote_url(provider_registry, &remote_url) + else { + return Err(anyhow::anyhow!("Unsupported remote URL: {}", remote_url)); + }; + + let Some(url) = provider.build_create_pull_request_url(&parsed_remote, &source_branch) + else { + return Err(anyhow::anyhow!("Unable to construct pull request URL")); + }; + + cx.open_url(url.as_str()); + Ok(()) + })(); + + if let Err(err) = result { + log::error!("Error while creating pull request {:?}", err); + cx.defer_in(window, |panel, _window, cx| { + panel.show_error_toast("create pull request", err, cx); + }); + } + } + fn askpass_delegate( &self, operation: impl Into, @@ -3706,7 +3768,12 @@ impl GitPanel { } } - fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) { + fn show_remote_output( + &mut self, + action: RemoteAction, + info: RemoteCommandOutput, + cx: &mut Context, + ) { let Some(workspace) = self.workspace.upgrade() else { return; }; diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 053c41bf10c5d97f9f5326fd17d6b5bf91297a03..d414d53283be4440b0ef892f1f861a3e39c9424c 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -1,5 +1,6 @@ use std::any::Any; +use anyhow::anyhow; use command_palette_hooks::CommandPaletteFilter; use commit_modal::CommitModal; use editor::{Editor, actions::DiffClipboardWithSelectionData}; @@ -81,6 +82,15 @@ pub fn init(cx: &mut App) { return; } if !project.is_via_collab() { + workspace.register_action( + |workspace, _: &zed_actions::git::CreatePullRequest, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.create_pull_request(window, cx); + }); + } + }, + ); workspace.register_action(|workspace, _: &git::Fetch, window, cx| { let Some(panel) = workspace.panel::(cx) else { return; @@ -349,7 +359,7 @@ impl RenameBranchModal { { Ok(Ok(_)) => Ok(()), Ok(Err(error)) => Err(error), - Err(_) => Err(anyhow::anyhow!("Operation was canceled")), + Err(_) => Err(anyhow!("Operation was canceled")), } }) .detach_and_prompt_err("Failed to rename branch", window, cx, |_, _, _| None); diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 85b6d4d37d06d5f1c229fc852dd5bad117bbd9d7..ab126fd4db53d1ce37eba883c142e40ea1371ee7 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -227,7 +227,9 @@ pub mod git { /// Opens the git stash selector. ViewStash, /// Opens the git worktree selector. - Worktree + Worktree, + /// Creates a pull request for the current branch. + CreatePullRequest ] ); }