Add a registry for `GitHostingProvider`s (#11470)

Marshall Bowers created

This PR adds a registry for `GitHostingProvider`s.

The intent here is to help decouple these provider-specific concerns
from the lower-level `git` crate.

Similar to languages, the Git hosting providers live in the new
`git_hosting_providers` crate.

This work also lays the foundation for if we wanted to allow defining a
`GitHostingProvider` from within an extension. This could be useful if
we wanted to extend the support to work with self-hosted Git providers
(like GitHub Enterprise).

I also took the opportunity to move some of the provider-specific code
out of the `util` crate, since it had leaked into there.

Release Notes:

- N/A

Change summary

Cargo.lock                                                | 25 ++
Cargo.toml                                                |  2 
crates/collab/Cargo.toml                                  |  1 
crates/collab/src/tests/test_server.rs                    |  6 
crates/editor/src/editor.rs                               |  7 
crates/editor/src/git/blame.rs                            | 19 +
crates/fs/src/fs.rs                                       | 13 +
crates/git/Cargo.toml                                     |  9 
crates/git/src/blame.rs                                   |  8 
crates/git/src/git.rs                                     |  2 
crates/git/src/hosting_provider.rs                        | 91 +++++++-
crates/git/src/repository.rs                              | 10 
crates/git_hosting_providers/Cargo.toml                   | 30 ++
crates/git_hosting_providers/LICENSE-GPL                  |  1 
crates/git_hosting_providers/src/git_hosting_providers.rs | 26 ++
crates/git_hosting_providers/src/providers.rs             |  0 
crates/git_hosting_providers/src/providers/bitbucket.rs   | 20 +
crates/git_hosting_providers/src/providers/codeberg.rs    | 87 +++++++
crates/git_hosting_providers/src/providers/gitee.rs       |  4 
crates/git_hosting_providers/src/providers/github.rs      | 91 +++++++-
crates/git_hosting_providers/src/providers/gitlab.rs      |  4 
crates/git_hosting_providers/src/providers/sourcehut.rs   |  4 
crates/util/src/codeberg.rs                               | 78 -------
crates/util/src/git_author.rs                             |  5 
crates/util/src/github.rs                                 | 72 -------
crates/util/src/util.rs                                   |  2 
crates/zed/Cargo.toml                                     |  2 
crates/zed/src/main.rs                                    | 15 +
28 files changed, 405 insertions(+), 229 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2286,6 +2286,7 @@ dependencies = [
  "fs",
  "futures 0.3.28",
  "git",
+ "git_hosting_providers",
  "google_ai",
  "gpui",
  "headless",
@@ -4394,12 +4395,13 @@ dependencies = [
  "async-trait",
  "clock",
  "collections",
+ "derive_more",
  "git2",
+ "gpui",
  "lazy_static",
  "log",
  "parking_lot",
  "pretty_assertions",
- "regex",
  "rope",
  "serde",
  "serde_json",
@@ -4426,6 +4428,25 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "git_hosting_providers"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "futures 0.3.28",
+ "git",
+ "gpui",
+ "isahc",
+ "pretty_assertions",
+ "regex",
+ "serde",
+ "serde_json",
+ "unindent",
+ "url",
+ "util",
+]
+
 [[package]]
 name = "glob"
 version = "0.3.1"
@@ -12766,6 +12787,8 @@ dependencies = [
  "file_icons",
  "fs",
  "futures 0.3.28",
+ "git",
+ "git_hosting_providers",
  "go_to_line",
  "gpui",
  "headless",

Cargo.toml 🔗

@@ -35,6 +35,7 @@ members = [
     "crates/fsevent",
     "crates/fuzzy",
     "crates/git",
+    "crates/git_hosting_providers",
     "crates/go_to_line",
     "crates/google_ai",
     "crates/gpui",
@@ -174,6 +175,7 @@ fs = { path = "crates/fs" }
 fsevent = { path = "crates/fsevent" }
 fuzzy = { path = "crates/fuzzy" }
 git = { path = "crates/git" }
+git_hosting_providers = { path = "crates/git_hosting_providers" }
 go_to_line = { path = "crates/go_to_line" }
 google_ai = { path = "crates/google_ai" }
 gpui = { path = "crates/gpui" }

crates/collab/Cargo.toml 🔗

@@ -83,6 +83,7 @@ env_logger.workspace = true
 file_finder.workspace = true
 fs = { workspace = true, features = ["test-support"] }
 git = { workspace = true, features = ["test-support"] }
+git_hosting_providers.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 indoc.workspace = true
 language = { workspace = true, features = ["test-support"] }

crates/collab/src/tests/test_server.rs 🔗

@@ -17,6 +17,7 @@ use collab_ui::channel_view::ChannelView;
 use collections::{HashMap, HashSet};
 use fs::FakeFs;
 use futures::{channel::oneshot, StreamExt as _};
+use git::GitHostingProviderRegistry;
 use gpui::{BackgroundExecutor, Context, Model, Task, TestAppContext, View, VisualTestContext};
 use language::LanguageRegistry;
 use node_runtime::FakeNodeRuntime;
@@ -257,6 +258,11 @@ impl TestServer {
                 })
             });
 
+        let git_hosting_provider_registry =
+            cx.update(|cx| GitHostingProviderRegistry::default_global(cx));
+        git_hosting_provider_registry
+            .register_hosting_provider(Arc::new(git_hosting_providers::Github));
+
         let fs = FakeFs::new(cx.executor());
         let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
         let workspace_store = cx.new_model(|cx| WorkspaceStore::new(client.clone(), cx));

crates/editor/src/editor.rs 🔗

@@ -41,7 +41,7 @@ mod editor_tests;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 use ::git::diff::{DiffHunk, DiffHunkStatus};
-use ::git::{parse_git_remote_url, BuildPermalinkParams};
+use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
 pub(crate) use actions::*;
 use aho_corasick::AhoCorasick;
 use anyhow::{anyhow, Context as _, Result};
@@ -9548,8 +9548,9 @@ impl Editor {
         let selections = self.selections.all::<Point>(cx);
         let selection = selections.iter().peekable().next();
 
-        let (provider, remote) = parse_git_remote_url(&origin_url)
-            .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
+        let (provider, remote) =
+            parse_git_remote_url(GitHostingProviderRegistry::default_global(cx), &origin_url)
+                .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
 
         Ok(provider.build_permalink(
             remote,

crates/editor/src/git/blame.rs 🔗

@@ -4,7 +4,7 @@ use anyhow::Result;
 use collections::HashMap;
 use git::{
     blame::{Blame, BlameEntry},
-    parse_git_remote_url, GitHostingProvider, Oid, PullRequest,
+    parse_git_remote_url, GitHostingProvider, GitHostingProviderRegistry, Oid, PullRequest,
 };
 use gpui::{Model, ModelContext, Subscription, Task};
 use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown};
@@ -330,6 +330,7 @@ impl GitBlame {
         let snapshot = self.buffer.read(cx).snapshot();
         let blame = self.project.read(cx).blame_buffer(&self.buffer, None, cx);
         let languages = self.project.read(cx).languages().clone();
+        let provider_registry = GitHostingProviderRegistry::default_global(cx);
 
         self.task = cx.spawn(|this, mut cx| async move {
             let result = cx
@@ -345,9 +346,14 @@ impl GitBlame {
                         } = blame.await?;
 
                         let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
-                        let commit_details =
-                            parse_commit_messages(messages, remote_url, &permalinks, &languages)
-                                .await;
+                        let commit_details = parse_commit_messages(
+                            messages,
+                            remote_url,
+                            &permalinks,
+                            provider_registry,
+                            &languages,
+                        )
+                        .await;
 
                         anyhow::Ok((entries, commit_details))
                     }
@@ -438,11 +444,14 @@ async fn parse_commit_messages(
     messages: impl IntoIterator<Item = (Oid, String)>,
     remote_url: Option<String>,
     deprecated_permalinks: &HashMap<Oid, Url>,
+    provider_registry: Arc<GitHostingProviderRegistry>,
     languages: &Arc<LanguageRegistry>,
 ) -> HashMap<Oid, CommitDetails> {
     let mut commit_details = HashMap::default();
 
-    let parsed_remote_url = remote_url.as_deref().and_then(parse_git_remote_url);
+    let parsed_remote_url = remote_url
+        .as_deref()
+        .and_then(|remote_url| parse_git_remote_url(provider_registry, remote_url));
 
     for (oid, message) in messages {
         let parsed_message = parse_markdown(&message, &languages).await;

crates/fs/src/fs.rs 🔗

@@ -1,4 +1,5 @@
 use anyhow::{anyhow, Result};
+use git::GitHostingProviderRegistry;
 
 #[cfg(unix)]
 use std::os::unix::fs::MetadataExt;
@@ -117,12 +118,19 @@ pub struct Metadata {
 
 #[derive(Default)]
 pub struct RealFs {
+    git_hosting_provider_registry: Arc<GitHostingProviderRegistry>,
     git_binary_path: Option<PathBuf>,
 }
 
 impl RealFs {
-    pub fn new(git_binary_path: Option<PathBuf>) -> Self {
-        Self { git_binary_path }
+    pub fn new(
+        git_hosting_provider_registry: Arc<GitHostingProviderRegistry>,
+        git_binary_path: Option<PathBuf>,
+    ) -> Self {
+        Self {
+            git_hosting_provider_registry,
+            git_binary_path,
+        }
     }
 }
 
@@ -474,6 +482,7 @@ impl Fs for RealFs {
                 Arc::new(Mutex::new(RealGitRepository::new(
                     libgit_repository,
                     self.git_binary_path.clone(),
+                    self.git_hosting_provider_registry.clone(),
                 )))
             })
     }

crates/git/Cargo.toml 🔗

@@ -16,19 +16,20 @@ anyhow.workspace = true
 async-trait.workspace = true
 clock.workspace = true
 collections.workspace = true
+derive_more.workspace = true
 git2.workspace = true
+gpui.workspace = true
 lazy_static.workspace = true
 log.workspace = true
+parking_lot.workspace = true
+rope.workspace = true
+serde.workspace = true
 smol.workspace = true
 sum_tree.workspace = true
 text.workspace = true
 time.workspace = true
 url.workspace = true
 util.workspace = true
-serde.workspace = true
-regex.workspace = true
-rope.workspace = true
-parking_lot.workspace = true
 windows.workspace = true
 
 [dev-dependencies]

crates/git/src/blame.rs 🔗

@@ -1,10 +1,11 @@
 use crate::commit::get_messages;
-use crate::{parse_git_remote_url, BuildCommitPermalinkParams, Oid};
+use crate::{parse_git_remote_url, BuildCommitPermalinkParams, GitHostingProviderRegistry, Oid};
 use anyhow::{anyhow, Context, Result};
 use collections::{HashMap, HashSet};
 use serde::{Deserialize, Serialize};
 use std::io::Write;
 use std::process::{Command, Stdio};
+use std::sync::Arc;
 use std::{ops::Range, path::Path};
 use text::Rope;
 use time;
@@ -33,6 +34,7 @@ impl Blame {
         path: &Path,
         content: &Rope,
         remote_url: Option<String>,
+        provider_registry: Arc<GitHostingProviderRegistry>,
     ) -> Result<Self> {
         let output = run_git_blame(git_binary, working_directory, path, &content)?;
         let mut entries = parse_git_blame(&output)?;
@@ -40,7 +42,9 @@ impl Blame {
 
         let mut permalinks = HashMap::default();
         let mut unique_shas = HashSet::default();
-        let parsed_remote_url = remote_url.as_deref().and_then(parse_git_remote_url);
+        let parsed_remote_url = remote_url
+            .as_deref()
+            .and_then(|remote_url| parse_git_remote_url(provider_registry, remote_url));
 
         for entry in entries.iter_mut() {
             unique_shas.insert(entry.sha);

crates/git/src/git.rs 🔗

@@ -1,5 +1,4 @@
 mod hosting_provider;
-mod hosting_providers;
 
 use anyhow::{anyhow, Context, Result};
 use serde::{Deserialize, Serialize};
@@ -11,7 +10,6 @@ pub use git2 as libgit;
 pub use lazy_static::lazy_static;
 
 pub use crate::hosting_provider::*;
-pub use crate::hosting_providers::*;
 
 pub mod blame;
 pub mod commit;

crates/git/src/hosting_provider.rs 🔗

@@ -2,10 +2,13 @@ use std::{ops::Range, sync::Arc};
 
 use anyhow::Result;
 use async_trait::async_trait;
+use collections::BTreeMap;
+use derive_more::{Deref, DerefMut};
+use gpui::{AppContext, Global};
+use parking_lot::RwLock;
 use url::Url;
 use util::http::HttpClient;
 
-use crate::hosting_providers::{Bitbucket, Codeberg, Gitee, Github, Gitlab, Sourcehut};
 use crate::Oid;
 
 #[derive(Debug, PartialEq, Eq, Clone)]
@@ -87,6 +90,69 @@ pub trait GitHostingProvider {
     }
 }
 
+#[derive(Default, Deref, DerefMut)]
+struct GlobalGitHostingProviderRegistry(Arc<GitHostingProviderRegistry>);
+
+impl Global for GlobalGitHostingProviderRegistry {}
+
+#[derive(Default)]
+struct GitHostingProviderRegistryState {
+    providers: BTreeMap<String, Arc<dyn GitHostingProvider + Send + Sync + 'static>>,
+}
+
+#[derive(Default)]
+pub struct GitHostingProviderRegistry {
+    state: RwLock<GitHostingProviderRegistryState>,
+}
+
+impl GitHostingProviderRegistry {
+    /// Returns the global [`GitHostingProviderRegistry`].
+    pub fn global(cx: &AppContext) -> Arc<Self> {
+        cx.global::<GlobalGitHostingProviderRegistry>().0.clone()
+    }
+
+    /// Returns the global [`GitHostingProviderRegistry`].
+    ///
+    /// Inserts a default [`GitHostingProviderRegistry`] if one does not yet exist.
+    pub fn default_global(cx: &mut AppContext) -> Arc<Self> {
+        cx.default_global::<GlobalGitHostingProviderRegistry>()
+            .0
+            .clone()
+    }
+
+    /// Sets the global [`GitHostingProviderRegistry`].
+    pub fn set_global(registry: Arc<GitHostingProviderRegistry>, cx: &mut AppContext) {
+        cx.set_global(GlobalGitHostingProviderRegistry(registry));
+    }
+
+    /// Returns a new [`GitHostingProviderRegistry`].
+    pub fn new() -> Self {
+        Self {
+            state: RwLock::new(GitHostingProviderRegistryState {
+                providers: BTreeMap::default(),
+            }),
+        }
+    }
+
+    /// Returns the list of all [`GitHostingProvider`]s in the registry.
+    pub fn list_hosting_providers(
+        &self,
+    ) -> Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>> {
+        self.state.read().providers.values().cloned().collect()
+    }
+
+    /// Adds the provided [`GitHostingProvider`] to the registry.
+    pub fn register_hosting_provider(
+        &self,
+        provider: Arc<dyn GitHostingProvider + Send + Sync + 'static>,
+    ) {
+        self.state
+            .write()
+            .providers
+            .insert(provider.name(), provider);
+    }
+}
+
 #[derive(Debug)]
 pub struct ParsedGitRemote<'a> {
     pub owner: &'a str,
@@ -94,23 +160,18 @@ pub struct ParsedGitRemote<'a> {
 }
 
 pub fn parse_git_remote_url(
+    provider_registry: Arc<GitHostingProviderRegistry>,
     url: &str,
 ) -> Option<(
     Arc<dyn GitHostingProvider + Send + Sync + 'static>,
     ParsedGitRemote,
 )> {
-    let providers: Vec<Arc<dyn GitHostingProvider + Send + Sync + 'static>> = vec![
-        Arc::new(Github),
-        Arc::new(Gitlab),
-        Arc::new(Bitbucket),
-        Arc::new(Codeberg),
-        Arc::new(Gitee),
-        Arc::new(Sourcehut),
-    ];
-
-    providers.into_iter().find_map(|provider| {
-        provider
-            .parse_remote_url(&url)
-            .map(|parsed_remote| (provider, parsed_remote))
-    })
+    provider_registry
+        .list_hosting_providers()
+        .into_iter()
+        .find_map(|provider| {
+            provider
+                .parse_remote_url(&url)
+                .map(|parsed_remote| (provider, parsed_remote))
+        })
 }

crates/git/src/repository.rs 🔗

@@ -1,4 +1,5 @@
 use crate::blame::Blame;
+use crate::GitHostingProviderRegistry;
 use anyhow::{Context, Result};
 use collections::HashMap;
 use git2::{BranchType, StatusShow};
@@ -71,13 +72,19 @@ impl std::fmt::Debug for dyn GitRepository {
 pub struct RealGitRepository {
     pub repository: LibGitRepository,
     pub git_binary_path: PathBuf,
+    hosting_provider_registry: Arc<GitHostingProviderRegistry>,
 }
 
 impl RealGitRepository {
-    pub fn new(repository: LibGitRepository, git_binary_path: Option<PathBuf>) -> Self {
+    pub fn new(
+        repository: LibGitRepository,
+        git_binary_path: Option<PathBuf>,
+        hosting_provider_registry: Arc<GitHostingProviderRegistry>,
+    ) -> Self {
         Self {
             repository,
             git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
+            hosting_provider_registry,
         }
     }
 }
@@ -246,6 +253,7 @@ impl GitRepository for RealGitRepository {
             path,
             &content,
             remote_url,
+            self.hosting_provider_registry.clone(),
         )
     }
 }

crates/git_hosting_providers/Cargo.toml 🔗

@@ -0,0 +1,30 @@
+[package]
+name = "git_hosting_providers"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/git_hosting_providers.rs"
+
+[dependencies]
+anyhow.workspace = true
+async-trait.workspace = true
+futures.workspace = true
+git.workspace = true
+gpui.workspace = true
+isahc.workspace = true
+regex.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+url.workspace = true
+util.workspace = true
+
+[dev-dependencies]
+unindent.workspace = true
+serde_json.workspace = true
+pretty_assertions.workspace = true

crates/git_hosting_providers/src/git_hosting_providers.rs 🔗

@@ -0,0 +1,26 @@
+mod providers;
+
+use std::sync::Arc;
+
+use git::GitHostingProviderRegistry;
+use gpui::AppContext;
+
+pub use crate::providers::*;
+
+/// Initializes the Git hosting providers.
+pub fn init(cx: &mut AppContext) {
+    let provider_registry = GitHostingProviderRegistry::global(cx);
+
+    // The providers are stored in a `BTreeMap`, so insertion order matters.
+    // GitHub comes first.
+    provider_registry.register_hosting_provider(Arc::new(Github));
+
+    // Then GitLab.
+    provider_registry.register_hosting_provider(Arc::new(Gitlab));
+
+    // Then the other providers, in the order they were added.
+    provider_registry.register_hosting_provider(Arc::new(Gitee));
+    provider_registry.register_hosting_provider(Arc::new(Bitbucket));
+    provider_registry.register_hosting_provider(Arc::new(Sourcehut));
+    provider_registry.register_hosting_provider(Arc::new(Codeberg));
+}

crates/git/src/hosting_providers/bitbucket.rs → crates/git_hosting_providers/src/providers/bitbucket.rs 🔗

@@ -1,8 +1,6 @@
 use url::Url;
 
-use crate::{
-    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
-};
+use git::{BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote};
 
 pub struct Bitbucket;
 
@@ -77,14 +75,18 @@ impl GitHostingProvider for Bitbucket {
 
 #[cfg(test)]
 mod tests {
-    use crate::parse_git_remote_url;
+    use std::sync::Arc;
+
+    use git::{parse_git_remote_url, GitHostingProviderRegistry};
 
     use super::*;
 
     #[test]
     fn test_parse_git_remote_url_bitbucket_https_with_username() {
+        let provider_registry = Arc::new(GitHostingProviderRegistry::new());
+        provider_registry.register_hosting_provider(Arc::new(Bitbucket));
         let url = "https://thorstenballzed@bitbucket.org/thorstenzed/testingrepo.git";
-        let (provider, parsed) = parse_git_remote_url(url).unwrap();
+        let (provider, parsed) = parse_git_remote_url(provider_registry, url).unwrap();
         assert_eq!(provider.name(), "Bitbucket");
         assert_eq!(parsed.owner, "thorstenzed");
         assert_eq!(parsed.repo, "testingrepo");
@@ -92,8 +94,10 @@ mod tests {
 
     #[test]
     fn test_parse_git_remote_url_bitbucket_https_without_username() {
+        let provider_registry = Arc::new(GitHostingProviderRegistry::new());
+        provider_registry.register_hosting_provider(Arc::new(Bitbucket));
         let url = "https://bitbucket.org/thorstenzed/testingrepo.git";
-        let (provider, parsed) = parse_git_remote_url(url).unwrap();
+        let (provider, parsed) = parse_git_remote_url(provider_registry, url).unwrap();
         assert_eq!(provider.name(), "Bitbucket");
         assert_eq!(parsed.owner, "thorstenzed");
         assert_eq!(parsed.repo, "testingrepo");
@@ -101,8 +105,10 @@ mod tests {
 
     #[test]
     fn test_parse_git_remote_url_bitbucket_git() {
+        let provider_registry = Arc::new(GitHostingProviderRegistry::new());
+        provider_registry.register_hosting_provider(Arc::new(Bitbucket));
         let url = "git@bitbucket.org:thorstenzed/testingrepo.git";
-        let (provider, parsed) = parse_git_remote_url(url).unwrap();
+        let (provider, parsed) = parse_git_remote_url(provider_registry, url).unwrap();
         assert_eq!(provider.name(), "Bitbucket");
         assert_eq!(parsed.owner, "thorstenzed");
         assert_eq!(parsed.repo, "testingrepo");

crates/git/src/hosting_providers/codeberg.rs → crates/git_hosting_providers/src/providers/codeberg.rs 🔗

@@ -1,17 +1,88 @@
 use std::sync::Arc;
 
-use anyhow::Result;
+use anyhow::{bail, Context, Result};
 use async_trait::async_trait;
+use futures::AsyncReadExt;
+use isahc::config::Configurable;
+use isahc::{AsyncBody, Request};
+use serde::Deserialize;
 use url::Url;
-use util::codeberg;
 use util::http::HttpClient;
 
-use crate::{
+use git::{
     BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
 };
 
+#[derive(Debug, Deserialize)]
+struct CommitDetails {
+    commit: Commit,
+    author: Option<User>,
+}
+
+#[derive(Debug, Deserialize)]
+struct Commit {
+    author: Author,
+}
+
+#[derive(Debug, Deserialize)]
+struct Author {
+    name: String,
+    email: String,
+    date: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct User {
+    pub login: String,
+    pub id: u64,
+    pub avatar_url: String,
+}
+
 pub struct Codeberg;
 
+impl Codeberg {
+    async fn fetch_codeberg_commit_author(
+        &self,
+        repo_owner: &str,
+        repo: &str,
+        commit: &str,
+        client: &Arc<dyn HttpClient>,
+    ) -> Result<Option<User>> {
+        let url =
+            format!("https://codeberg.org/api/v1/repos/{repo_owner}/{repo}/git/commits/{commit}");
+
+        let mut request = Request::get(&url)
+            .redirect_policy(isahc::config::RedirectPolicy::Follow)
+            .header("Content-Type", "application/json");
+
+        if let Ok(codeberg_token) = std::env::var("CODEBERG_TOKEN") {
+            request = request.header("Authorization", format!("Bearer {}", codeberg_token));
+        }
+
+        let mut response = client
+            .send(request.body(AsyncBody::default())?)
+            .await
+            .with_context(|| format!("error fetching Codeberg 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)?;
+
+        serde_json::from_str::<CommitDetails>(body_str)
+            .map(|commit| commit.author)
+            .context("failed to deserialize Codeberg commit details")
+    }
+}
+
 #[async_trait]
 impl GitHostingProvider for Codeberg {
     fn name(&self) -> String {
@@ -90,11 +161,11 @@ impl GitHostingProvider for Codeberg {
         http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
         let commit = commit.to_string();
-        let avatar_url =
-            codeberg::fetch_codeberg_commit_author(repo_owner, repo, &commit, &http_client)
-                .await?
-                .map(|author| Url::parse(&author.avatar_url))
-                .transpose()?;
+        let avatar_url = self
+            .fetch_codeberg_commit_author(repo_owner, repo, &commit, &http_client)
+            .await?
+            .map(|author| Url::parse(&author.avatar_url))
+            .transpose()?;
         Ok(avatar_url)
     }
 }

crates/git/src/hosting_providers/gitee.rs → crates/git_hosting_providers/src/providers/gitee.rs 🔗

@@ -1,8 +1,6 @@
 use url::Url;
 
-use crate::{
-    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
-};
+use git::{BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote};
 
 pub struct Gitee;
 

crates/git/src/hosting_providers/github.rs → crates/git_hosting_providers/src/providers/github.rs 🔗

@@ -1,13 +1,16 @@
 use std::sync::{Arc, OnceLock};
 
-use anyhow::Result;
+use anyhow::{bail, Context, Result};
 use async_trait::async_trait;
+use futures::AsyncReadExt;
+use isahc::config::Configurable;
+use isahc::{AsyncBody, Request};
 use regex::Regex;
+use serde::Deserialize;
 use url::Url;
-use util::github;
 use util::http::HttpClient;
 
-use crate::{
+use git::{
     BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
     PullRequest,
 };
@@ -18,8 +21,72 @@ fn pull_request_number_regex() -> &'static Regex {
     PULL_REQUEST_NUMBER_REGEX.get_or_init(|| Regex::new(r"\(#(\d+)\)$").unwrap())
 }
 
+#[derive(Debug, Deserialize)]
+struct CommitDetails {
+    commit: Commit,
+    author: Option<User>,
+}
+
+#[derive(Debug, Deserialize)]
+struct Commit {
+    author: Author,
+}
+
+#[derive(Debug, Deserialize)]
+struct Author {
+    email: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct User {
+    pub id: u64,
+    pub avatar_url: String,
+}
+
 pub struct Github;
 
+impl Github {
+    async fn fetch_github_commit_author(
+        &self,
+        repo_owner: &str,
+        repo: &str,
+        commit: &str,
+        client: &Arc<dyn HttpClient>,
+    ) -> Result<Option<User>> {
+        let url = format!("https://api.github.com/repos/{repo_owner}/{repo}/commits/{commit}");
+
+        let mut request = Request::get(&url)
+            .redirect_policy(isahc::config::RedirectPolicy::Follow)
+            .header("Content-Type", "application/json");
+
+        if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
+            request = request.header("Authorization", format!("Bearer {}", github_token));
+        }
+
+        let mut response = client
+            .send(request.body(AsyncBody::default())?)
+            .await
+            .with_context(|| format!("error fetching GitHub 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)?;
+
+        serde_json::from_str::<CommitDetails>(body_str)
+            .map(|commit| commit.author)
+            .context("failed to deserialize GitHub commit details")
+    }
+}
+
 #[async_trait]
 impl GitHostingProvider for Github {
     fn name(&self) -> String {
@@ -110,15 +177,15 @@ impl GitHostingProvider for Github {
         http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
         let commit = commit.to_string();
-        let avatar_url =
-            github::fetch_github_commit_author(repo_owner, repo, &commit, &http_client)
-                .await?
-                .map(|author| -> Result<Url, url::ParseError> {
-                    let mut url = Url::parse(&author.avatar_url)?;
-                    url.set_query(Some("size=128"));
-                    Ok(url)
-                })
-                .transpose()?;
+        let avatar_url = self
+            .fetch_github_commit_author(repo_owner, repo, &commit, &http_client)
+            .await?
+            .map(|author| -> Result<Url, url::ParseError> {
+                let mut url = Url::parse(&author.avatar_url)?;
+                url.set_query(Some("size=128"));
+                Ok(url)
+            })
+            .transpose()?;
         Ok(avatar_url)
     }
 }

crates/git/src/hosting_providers/gitlab.rs → crates/git_hosting_providers/src/providers/gitlab.rs 🔗

@@ -1,8 +1,6 @@
 use url::Url;
 
-use crate::{
-    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
-};
+use git::{BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote};
 
 pub struct Gitlab;
 

crates/git/src/hosting_providers/sourcehut.rs → crates/git_hosting_providers/src/providers/sourcehut.rs 🔗

@@ -1,8 +1,6 @@
 use url::Url;
 
-use crate::{
-    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
-};
+use git::{BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote};
 
 pub struct Sourcehut;
 

crates/util/src/codeberg.rs 🔗

@@ -1,78 +0,0 @@
-use crate::{git_author::GitAuthor, http::HttpClient};
-use anyhow::{bail, Context, Result};
-use futures::AsyncReadExt;
-use isahc::{config::Configurable, AsyncBody, Request};
-use serde::Deserialize;
-use std::sync::Arc;
-
-#[derive(Debug, Deserialize)]
-struct CommitDetails {
-    commit: Commit,
-    author: Option<User>,
-}
-
-#[derive(Debug, Deserialize)]
-struct Commit {
-    author: Author,
-}
-
-#[derive(Debug, Deserialize)]
-struct Author {
-    name: String,
-    email: String,
-    date: String,
-}
-
-#[derive(Debug, Deserialize)]
-struct User {
-    pub login: String,
-    pub id: u64,
-    pub avatar_url: String,
-}
-
-pub async fn fetch_codeberg_commit_author(
-    repo_owner: &str,
-    repo: &str,
-    commit: &str,
-    client: &Arc<dyn HttpClient>,
-) -> Result<Option<GitAuthor>> {
-    let url = format!("https://codeberg.org/api/v1/repos/{repo_owner}/{repo}/git/commits/{commit}");
-
-    let mut request = Request::get(&url)
-        .redirect_policy(isahc::config::RedirectPolicy::Follow)
-        .header("Content-Type", "application/json");
-
-    if let Ok(codeberg_token) = std::env::var("CODEBERG_TOKEN") {
-        request = request.header("Authorization", format!("Bearer {}", codeberg_token));
-    }
-
-    let mut response = client
-        .send(request.body(AsyncBody::default())?)
-        .await
-        .with_context(|| format!("error fetching Codeberg 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)?;
-
-    serde_json::from_str::<CommitDetails>(body_str)
-        .map(|codeberg_commit| {
-            if let Some(author) = codeberg_commit.author {
-                Some(GitAuthor {
-                    avatar_url: author.avatar_url,
-                })
-            } else {
-                None
-            }
-        })
-        .context("deserializing Codeberg commit details failed")
-}

crates/util/src/git_author.rs 🔗

@@ -1,5 +0,0 @@
-/// Represents the common denominator of most git hosting authors
-#[derive(Debug)]
-pub struct GitAuthor {
-    pub avatar_url: String,
-}

crates/util/src/github.rs 🔗

@@ -1,7 +1,6 @@
-use crate::{git_author::GitAuthor, http::HttpClient};
+use crate::http::HttpClient;
 use anyhow::{anyhow, bail, Context, Result};
 use futures::AsyncReadExt;
-use isahc::{config::Configurable, AsyncBody, Request};
 use serde::Deserialize;
 use std::sync::Arc;
 use url::Url;
@@ -27,75 +26,6 @@ pub struct GithubReleaseAsset {
     pub browser_download_url: String,
 }
 
-#[derive(Debug, Deserialize)]
-struct CommitDetails {
-    commit: Commit,
-    author: Option<User>,
-}
-
-#[derive(Debug, Deserialize)]
-struct Commit {
-    author: Author,
-}
-
-#[derive(Debug, Deserialize)]
-struct Author {
-    email: String,
-}
-
-#[derive(Debug, Deserialize)]
-struct User {
-    pub id: u64,
-    pub avatar_url: String,
-}
-
-pub async fn fetch_github_commit_author(
-    repo_owner: &str,
-    repo: &str,
-    commit: &str,
-    client: &Arc<dyn HttpClient>,
-) -> Result<Option<GitAuthor>> {
-    let url = format!("https://api.github.com/repos/{repo_owner}/{repo}/commits/{commit}");
-
-    let mut request = Request::get(&url)
-        .redirect_policy(isahc::config::RedirectPolicy::Follow)
-        .header("Content-Type", "application/json");
-
-    if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
-        request = request.header("Authorization", format!("Bearer {}", github_token));
-    }
-
-    let mut response = client
-        .send(request.body(AsyncBody::default())?)
-        .await
-        .with_context(|| format!("error fetching GitHub 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)?;
-
-    serde_json::from_str::<CommitDetails>(body_str)
-        .map(|github_commit| {
-            if let Some(author) = github_commit.author {
-                Some(GitAuthor {
-                    avatar_url: author.avatar_url,
-                })
-            } else {
-                None
-            }
-        })
-        .context("deserializing GitHub commit details failed")
-}
-
 pub async fn latest_github_release(
     repo_name_with_owner: &str,
     require_assets: bool,

crates/util/src/util.rs 🔗

@@ -1,7 +1,5 @@
 pub mod arc_cow;
-pub mod codeberg;
 pub mod fs;
-mod git_author;
 pub mod github;
 pub mod http;
 pub mod paths;

crates/zed/Cargo.toml 🔗

@@ -46,6 +46,8 @@ file_icons.workspace = true
 file_finder.workspace = true
 fs.workspace = true
 futures.workspace = true
+git.workspace = true
+git_hosting_providers.workspace = true
 go_to_line.workspace = true
 gpui.workspace = true
 headless.workspace = true

crates/zed/src/main.rs 🔗

@@ -16,6 +16,7 @@ use editor::Editor;
 use env_logger::Builder;
 use fs::RealFs;
 use futures::{future, StreamExt};
+use git::GitHostingProviderRegistry;
 use gpui::{App, AppContext, AsyncAppContext, Context, Task, VisualContext};
 use image_viewer;
 use language::LanguageRegistry;
@@ -119,6 +120,7 @@ fn init_headless(dev_server_token: DevServerToken) {
         project::Project::init(&client, cx);
         client::init(&client, cx);
 
+        let git_hosting_provider_registry = GitHostingProviderRegistry::default_global(cx);
         let git_binary_path = if option_env!("ZED_BUNDLE").as_deref() == Some("true") {
             cx.path_for_auxiliary_executable("git")
                 .context("could not find git binary path")
@@ -126,7 +128,9 @@ fn init_headless(dev_server_token: DevServerToken) {
         } else {
             None
         };
-        let fs = Arc::new(RealFs::new(git_binary_path));
+        let fs = Arc::new(RealFs::new(git_hosting_provider_registry, git_binary_path));
+
+        git_hosting_providers::init(cx);
 
         let mut languages =
             LanguageRegistry::new(Task::ready(()), cx.background_executor().clone());
@@ -186,6 +190,7 @@ fn init_ui(args: Args) {
     let session_id = Uuid::new_v4().to_string();
     reliability::init_panic_hook(&app, installation_id.clone(), session_id.clone());
 
+    let git_hosting_provider_registry = Arc::new(GitHostingProviderRegistry::new());
     let git_binary_path = if option_env!("ZED_BUNDLE").as_deref() == Some("true") {
         app.path_for_auxiliary_executable("git")
             .context("could not find git binary path")
@@ -195,7 +200,10 @@ fn init_ui(args: Args) {
     };
     log::info!("Using git binary path: {:?}", git_binary_path);
 
-    let fs = Arc::new(RealFs::new(git_binary_path));
+    let fs = Arc::new(RealFs::new(
+        git_hosting_provider_registry.clone(),
+        git_binary_path,
+    ));
     let user_settings_file_rx = watch_config_file(
         &app.background_executor(),
         fs.clone(),
@@ -236,6 +244,9 @@ fn init_ui(args: Args) {
             AppCommitSha::set_global(AppCommitSha(build_sha.into()), cx);
         }
 
+        GitHostingProviderRegistry::set_global(git_hosting_provider_registry, cx);
+        git_hosting_providers::init(cx);
+
         SystemAppearance::init(cx);
         OpenListener::set_global(listener.clone(), cx);