git: Improve self-hosted provider support and Bitbucket integration (#42343)

This PR includes several minor modifications and improvements related to
Git hosting providers, covering the following areas:

1. Bitbucket Owner Parsing Fix: Remove the common `scm` prefix from the
remote URL of self-hosted Bitbucket instances to prevent incorrect owner
parsing.
[Reference](a6e3c6fbb2/src/git/remotes/bitbucket-server.ts (L72-L74))
2. Bitbucket Avatars in Blame: Add support for displaying Bitbucket
avatars in the Git blame view.
<img width="2750" height="1994" alt="CleanShot 2025-11-10 at 20 34
40@2x"
src="https://github.com/user-attachments/assets/9e26abdf-7880-4085-b636-a1f99ebeeb97"
/>
3. Self-hosted SourceHut Support: Add support for self-hosted SourceHut
instances.
4. Configuration: Add recently introduced self-hosted Git providers
(Gitea, Forgejo, and SourceHut) to the `git_hosting_providers` setting
option.
<img width="2750" height="1994" alt="CleanShot 2025-11-10 at 20 33
48@2x"
src="https://github.com/user-attachments/assets/44ffc799-182d-4145-9b89-e509bbc08843"
/>


Closes #11043

Release Notes:

- Improved self-hosted git provider support and Bitbucket integration
This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ 2025-12-09 02:32:14 +08:00 committed by GitHub
parent bc17491527
commit b948d8b9e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 354 additions and 21 deletions

1
Cargo.lock generated
View file

@ -7008,6 +7008,7 @@ dependencies = [
"gpui",
"http_client",
"indoc",
"itertools 0.14.0",
"pretty_assertions",
"regex",
"serde",

View file

@ -18,6 +18,7 @@ futures.workspace = true
git.workspace = true
gpui.workspace = true
http_client.workspace = true
itertools.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true

View file

@ -26,7 +26,7 @@ pub fn init(cx: &mut App) {
provider_registry.register_hosting_provider(Arc::new(Gitee));
provider_registry.register_hosting_provider(Arc::new(Github::public_instance()));
provider_registry.register_hosting_provider(Arc::new(Gitlab::public_instance()));
provider_registry.register_hosting_provider(Arc::new(Sourcehut));
provider_registry.register_hosting_provider(Arc::new(SourceHut::public_instance()));
}
/// Registers additional Git hosting providers.
@ -51,6 +51,8 @@ pub async fn register_additional_providers(
provider_registry.register_hosting_provider(Arc::new(gitea_self_hosted));
} else if let Ok(bitbucket_self_hosted) = Bitbucket::from_remote_url(&origin_url) {
provider_registry.register_hosting_provider(Arc::new(bitbucket_self_hosted));
} else if let Ok(sourcehut_self_hosted) = SourceHut::from_remote_url(&origin_url) {
provider_registry.register_hosting_provider(Arc::new(sourcehut_self_hosted));
}
}

View file

@ -1,8 +1,14 @@
use std::str::FromStr;
use std::sync::LazyLock;
use std::{str::FromStr, sync::Arc};
use anyhow::{Result, bail};
use anyhow::{Context as _, Result, bail};
use async_trait::async_trait;
use futures::AsyncReadExt;
use gpui::SharedString;
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
use itertools::Itertools as _;
use regex::Regex;
use serde::Deserialize;
use url::Url;
use git::{
@ -20,6 +26,42 @@ fn pull_request_regex() -> &'static Regex {
&PULL_REQUEST_REGEX
}
#[derive(Debug, Deserialize)]
struct CommitDetails {
author: Author,
}
#[derive(Debug, Deserialize)]
struct Author {
user: Account,
}
#[derive(Debug, Deserialize)]
struct Account {
links: AccountLinks,
}
#[derive(Debug, Deserialize)]
struct AccountLinks {
avatar: Option<Link>,
}
#[derive(Debug, Deserialize)]
struct Link {
href: String,
}
#[derive(Debug, Deserialize)]
struct CommitDetailsSelfHosted {
author: AuthorSelfHosted,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthorSelfHosted {
avatar_url: Option<String>,
}
pub struct Bitbucket {
name: String,
base_url: Url,
@ -61,8 +103,60 @@ impl Bitbucket {
.host_str()
.is_some_and(|host| host != "bitbucket.org")
}
async fn fetch_bitbucket_commit_author(
&self,
repo_owner: &str,
repo: &str,
commit: &str,
client: &Arc<dyn HttpClient>,
) -> Result<Option<String>> {
let Some(host) = self.base_url.host_str() else {
bail!("failed to get host from bitbucket base url");
};
let is_self_hosted = self.is_self_hosted();
let url = if is_self_hosted {
format!(
"https://{host}/rest/api/latest/projects/{repo_owner}/repos/{repo}/commits/{commit}?avatarSize=128"
)
} else {
format!("https://api.{host}/2.0/repositories/{repo_owner}/{repo}/commit/{commit}")
};
let request = Request::get(&url)
.header("Content-Type", "application/json")
.follow_redirects(http_client::RedirectPolicy::FollowAll);
let mut response = client
.send(request.body(AsyncBody::default())?)
.await
.with_context(|| format!("error fetching BitBucket commit details at {:?}", url))?;
let mut body = Vec::new();
response.body_mut().read_to_end(&mut body).await?;
if response.status().is_client_error() {
let text = String::from_utf8_lossy(body.as_slice());
bail!(
"status error {}, response: {text:?}",
response.status().as_u16()
);
}
let body_str = std::str::from_utf8(&body)?;
if is_self_hosted {
serde_json::from_str::<CommitDetailsSelfHosted>(body_str)
.map(|commit| commit.author.avatar_url)
} else {
serde_json::from_str::<CommitDetails>(body_str)
.map(|commit| commit.author.user.links.avatar.map(|link| link.href))
}
.context("failed to deserialize BitBucket commit details")
}
}
#[async_trait]
impl GitHostingProvider for Bitbucket {
fn name(&self) -> String {
self.name.clone()
@ -73,7 +167,7 @@ impl GitHostingProvider for Bitbucket {
}
fn supports_avatars(&self) -> bool {
false
true
}
fn format_line_number(&self, line: u32) -> String {
@ -98,9 +192,16 @@ impl GitHostingProvider for Bitbucket {
return None;
}
let mut path_segments = url.path_segments()?;
let owner = path_segments.next()?;
let repo = path_segments.next()?.trim_end_matches(".git");
let mut path_segments = url.path_segments()?.collect::<Vec<_>>();
let repo = path_segments.pop()?.trim_end_matches(".git");
let owner = if path_segments.get(0).is_some_and(|v| *v == "scm") && path_segments.len() > 1
{
// Skip the "scm" segment if it's not the only segment
// https://github.com/gitkraken/vscode-gitlens/blob/a6e3c6fbb255116507eaabaa9940c192ed7bb0e1/src/git/remotes/bitbucket-server.ts#L72-L74
path_segments.into_iter().skip(1).join("/")
} else {
path_segments.into_iter().join("/")
};
Some(ParsedGitRemote {
owner: owner.into(),
@ -176,6 +277,22 @@ impl GitHostingProvider for Bitbucket {
Some(PullRequest { number, url })
}
async fn commit_author_avatar_url(
&self,
repo_owner: &str,
repo: &str,
commit: SharedString,
http_client: Arc<dyn HttpClient>,
) -> Result<Option<Url>> {
let commit = commit.to_string();
let avatar_url = self
.fetch_bitbucket_commit_author(repo_owner, repo, &commit, &http_client)
.await?
.map(|avatar_url| Url::parse(&avatar_url))
.transpose()?;
Ok(avatar_url)
}
}
#[cfg(test)]
@ -264,6 +381,38 @@ mod tests {
repo: "zed".into(),
}
);
// Test with "scm" in the path
let remote_url = "https://bitbucket.company.com/scm/zed-industries/zed.git";
let parsed_remote = Bitbucket::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
// Test with only "scm" as owner
let remote_url = "https://bitbucket.company.com/scm/zed.git";
let parsed_remote = Bitbucket::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "scm".into(),
repo: "zed".into(),
}
);
}
#[test]

View file

@ -1,5 +1,6 @@
use std::str::FromStr;
use anyhow::{Result, bail};
use url::Url;
use git::{
@ -7,15 +8,52 @@ use git::{
RemoteUrl,
};
pub struct Sourcehut;
use crate::get_host_from_git_remote_url;
impl GitHostingProvider for Sourcehut {
pub struct SourceHut {
name: String,
base_url: Url,
}
impl SourceHut {
pub fn new(name: &str, base_url: Url) -> Self {
Self {
name: name.to_string(),
base_url,
}
}
pub fn public_instance() -> Self {
Self::new("SourceHut", Url::parse("https://git.sr.ht").unwrap())
}
pub fn from_remote_url(remote_url: &str) -> Result<Self> {
let host = get_host_from_git_remote_url(remote_url)?;
if host == "git.sr.ht" {
bail!("the SourceHut instance is not self-hosted");
}
// TODO: detecting self hosted instances by checking whether "sourcehut" is in the url or not
// is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
// information.
if !host.contains("sourcehut") {
bail!("not a SourceHut URL");
}
Ok(Self::new(
"SourceHut Self-Hosted",
Url::parse(&format!("https://{}", host))?,
))
}
}
impl GitHostingProvider for SourceHut {
fn name(&self) -> String {
"SourceHut".to_string()
self.name.clone()
}
fn base_url(&self) -> Url {
Url::parse("https://git.sr.ht").unwrap()
self.base_url.clone()
}
fn supports_avatars(&self) -> bool {
@ -34,7 +72,7 @@ impl GitHostingProvider for Sourcehut {
let url = RemoteUrl::from_str(url).ok()?;
let host = url.host_str()?;
if host != "git.sr.ht" {
if host != self.base_url.host_str()? {
return None;
}
@ -96,7 +134,7 @@ mod tests {
#[test]
fn test_parse_remote_url_given_ssh_url() {
let parsed_remote = Sourcehut
let parsed_remote = SourceHut::public_instance()
.parse_remote_url("git@git.sr.ht:~zed-industries/zed")
.unwrap();
@ -111,7 +149,7 @@ mod tests {
#[test]
fn test_parse_remote_url_given_ssh_url_with_git_suffix() {
let parsed_remote = Sourcehut
let parsed_remote = SourceHut::public_instance()
.parse_remote_url("git@git.sr.ht:~zed-industries/zed.git")
.unwrap();
@ -126,7 +164,7 @@ mod tests {
#[test]
fn test_parse_remote_url_given_https_url() {
let parsed_remote = Sourcehut
let parsed_remote = SourceHut::public_instance()
.parse_remote_url("https://git.sr.ht/~zed-industries/zed")
.unwrap();
@ -139,9 +177,63 @@ mod tests {
);
}
#[test]
fn test_parse_remote_url_given_self_hosted_ssh_url() {
let remote_url = "git@sourcehut.org:~zed-industries/zed";
let parsed_remote = SourceHut::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_self_hosted_ssh_url_with_git_suffix() {
let remote_url = "git@sourcehut.org:~zed-industries/zed.git";
let parsed_remote = SourceHut::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed.git".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_self_hosted_https_url() {
let remote_url = "https://sourcehut.org/~zed-industries/zed";
let parsed_remote = SourceHut::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_build_sourcehut_permalink() {
let permalink = Sourcehut.build_permalink(
let permalink = SourceHut::public_instance().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
@ -159,7 +251,7 @@ mod tests {
#[test]
fn test_build_sourcehut_permalink_with_git_suffix() {
let permalink = Sourcehut.build_permalink(
let permalink = SourceHut::public_instance().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed.git".into(),
@ -175,9 +267,49 @@ mod tests {
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_self_hosted_permalink() {
let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed")
.unwrap()
.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams::new(
"faa6f979be417239b2e070dbbf6392b909224e0b",
&repo_path("crates/editor/src/git/permalink.rs"),
None,
),
);
let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_self_hosted_permalink_with_git_suffix() {
let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed.git")
.unwrap()
.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed.git".into(),
},
BuildPermalinkParams::new(
"faa6f979be417239b2e070dbbf6392b909224e0b",
&repo_path("crates/editor/src/git/permalink.rs"),
None,
),
);
let expected_url = "https://sourcehut.org/~zed-industries/zed.git/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_permalink_with_single_line_selection() {
let permalink = Sourcehut.build_permalink(
let permalink = SourceHut::public_instance().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
@ -195,7 +327,7 @@ mod tests {
#[test]
fn test_build_sourcehut_permalink_with_multi_line_selection() {
let permalink = Sourcehut.build_permalink(
let permalink = SourceHut::public_instance().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
@ -210,4 +342,44 @@ mod tests {
let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_self_hosted_permalink_with_single_line_selection() {
let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed")
.unwrap()
.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams::new(
"faa6f979be417239b2e070dbbf6392b909224e0b",
&repo_path("crates/editor/src/git/permalink.rs"),
Some(6..6),
),
);
let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L7";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
#[test]
fn test_build_sourcehut_self_hosted_permalink_with_multi_line_selection() {
let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed")
.unwrap()
.build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
BuildPermalinkParams::new(
"faa6f979be417239b2e070dbbf6392b909224e0b",
&repo_path("crates/editor/src/git/permalink.rs"),
Some(23..47),
),
);
let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48";
assert_eq!(permalink.to_string(), expected_url.to_string())
}
}

View file

@ -8,7 +8,7 @@ use settings::{
use url::Url;
use util::ResultExt as _;
use crate::{Bitbucket, Github, Gitlab};
use crate::{Bitbucket, Forgejo, Gitea, Github, Gitlab, SourceHut};
pub(crate) fn init(cx: &mut App) {
init_git_hosting_provider_settings(cx);
@ -46,6 +46,11 @@ fn update_git_hosting_providers_from_settings(cx: &mut App) {
}
GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _,
GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _,
GitHostingProviderKind::Gitea => Arc::new(Gitea::new(&provider.name, url)) as _,
GitHostingProviderKind::Forgejo => Arc::new(Forgejo::new(&provider.name, url)) as _,
GitHostingProviderKind::SourceHut => {
Arc::new(SourceHut::new(&provider.name, url)) as _
}
})
});

View file

@ -543,7 +543,7 @@ pub enum DiagnosticSeverityContent {
pub struct GitHostingProviderConfig {
/// The type of the provider.
///
/// Must be one of `github`, `gitlab`, or `bitbucket`.
/// Must be one of `github`, `gitlab`, `bitbucket`, `gitea`, `forgejo`, or `source_hut`.
pub provider: GitHostingProviderKind,
/// The base URL for the provider (e.g., "https://code.corp.big.com").
@ -559,4 +559,7 @@ pub enum GitHostingProviderKind {
Github,
Gitlab,
Bitbucket,
Gitea,
Forgejo,
SourceHut,
}