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

ᴀᴍᴛᴏᴀᴇʀ created

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](https://github.com/gitkraken/vscode-gitlens/blob/a6e3c6fbb255116507eaabaa9940c192ed7bb0e1/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

Change summary

Cargo.lock                                                |   1 
crates/git_hosting_providers/Cargo.toml                   |   1 
crates/git_hosting_providers/src/git_hosting_providers.rs |   4 
crates/git_hosting_providers/src/providers/bitbucket.rs   | 163 +++++++
crates/git_hosting_providers/src/providers/sourcehut.rs   | 196 ++++++++
crates/git_hosting_providers/src/settings.rs              |   7 
crates/settings/src/settings_content/project.rs           |   5 
7 files changed, 355 insertions(+), 22 deletions(-)

Detailed changes

Cargo.lock 🔗

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

crates/git_hosting_providers/Cargo.toml 🔗

@@ -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

crates/git_hosting_providers/src/git_hosting_providers.rs 🔗

@@ -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));
     }
 }
 

crates/git_hosting_providers/src/providers/bitbucket.rs 🔗

@@ -1,8 +1,14 @@
-use std::str::FromStr;
 use std::sync::LazyLock;
-
-use anyhow::{Result, bail};
+use std::{str::FromStr, sync::Arc};
+
+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]

crates/git_hosting_providers/src/providers/sourcehut.rs 🔗

@@ -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())
+    }
 }

crates/git_hosting_providers/src/settings.rs 🔗

@@ -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 _
+                }
             })
         });
 

crates/settings/src/settings_content/project.rs 🔗

@@ -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,
 }