Cargo.lock 🔗
@@ -7008,6 +7008,7 @@ dependencies = [
"gpui",
"http_client",
"indoc",
+ "itertools 0.14.0",
"pretty_assertions",
"regex",
"serde",
ᴀᴍᴛᴏᴀᴇʀ 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
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(-)
@@ -7008,6 +7008,7 @@ dependencies = [
"gpui",
"http_client",
"indoc",
+ "itertools 0.14.0",
"pretty_assertions",
"regex",
"serde",
@@ -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
@@ -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));
}
}
@@ -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]
@@ -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())
+ }
}
@@ -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 _
+ }
})
});
@@ -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,
}