Add support for self-hosted GitLab instances for Git permalinks (#19909)

Marshall Bowers created

This PR adds support for self-hosted GitLab instances when generating
Git permalinks.

If the `origin` Git remote contains `gitlab` in the URL hostname we will
then attempt to register it as a self-hosted GitLab instance.

A note on this: I don't think relying on specific keywords is going to
be a suitable long-term solution to detection. In reality the
self-hosted instance could be hosted anywhere (e.g.,
`vcs.my-company.com`), so we will ultimately need a way to have the user
indicate which Git provider they are using (perhaps via a setting).

Closes https://github.com/zed-industries/zed/issues/18012.

Release Notes:

- Added support for self-hosted GitLab instances when generating Git
permalinks.
- The instance URL must have `gitlab` somewhere in the host in order to
be recognized.

Change summary

Cargo.lock                                                |   2 
crates/git/src/hosting_provider.rs                        |   6 
crates/git_hosting_providers/Cargo.toml                   |   1 
crates/git_hosting_providers/src/git_hosting_providers.rs |  31 +
crates/git_hosting_providers/src/providers/gitlab.rs      | 111 ++++++++
crates/worktree/Cargo.toml                                |   1 
crates/worktree/src/worktree.rs                           |  11 
7 files changed, 141 insertions(+), 22 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4915,6 +4915,7 @@ dependencies = [
  "serde_json",
  "unindent",
  "url",
+ "util",
 ]
 
 [[package]]
@@ -14730,6 +14731,7 @@ dependencies = [
  "fuzzy",
  "git",
  "git2",
+ "git_hosting_providers",
  "gpui",
  "http_client",
  "ignore",

crates/git/src/hosting_provider.rs 🔗

@@ -111,6 +111,12 @@ impl GitHostingProviderRegistry {
         cx.global::<GlobalGitHostingProviderRegistry>().0.clone()
     }
 
+    /// Returns the global [`GitHostingProviderRegistry`], if one is set.
+    pub fn try_global(cx: &AppContext) -> Option<Arc<Self>> {
+        cx.try_global::<GlobalGitHostingProviderRegistry>()
+            .map(|registry| registry.0.clone())
+    }
+
     /// Returns the global [`GitHostingProviderRegistry`].
     ///
     /// Inserts a default [`GitHostingProviderRegistry`] if one does not yet exist.

crates/git_hosting_providers/Cargo.toml 🔗

@@ -22,6 +22,7 @@ regex.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 url.workspace = true
+util.workspace = true
 
 [dev-dependencies]
 unindent.workspace = true

crates/git_hosting_providers/src/git_hosting_providers.rs 🔗

@@ -2,6 +2,7 @@ mod providers;
 
 use std::sync::Arc;
 
+use git::repository::GitRepository;
 use git::GitHostingProviderRegistry;
 use gpui::AppContext;
 
@@ -10,17 +11,27 @@ pub use crate::providers::*;
 /// Initializes the Git hosting providers.
 pub fn init(cx: &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(Bitbucket));
+    provider_registry.register_hosting_provider(Arc::new(Codeberg));
+    provider_registry.register_hosting_provider(Arc::new(Gitee));
     provider_registry.register_hosting_provider(Arc::new(Github));
+    provider_registry.register_hosting_provider(Arc::new(Gitlab::new()));
+    provider_registry.register_hosting_provider(Arc::new(Sourcehut));
+}
 
-    // Then GitLab.
-    provider_registry.register_hosting_provider(Arc::new(Gitlab));
+/// Registers additional Git hosting providers.
+///
+/// These require information from the Git repository to construct, so their
+/// registration is deferred until we have a Git repository initialized.
+pub fn register_additional_providers(
+    provider_registry: Arc<GitHostingProviderRegistry>,
+    repository: Arc<dyn GitRepository>,
+) {
+    let Some(origin_url) = repository.remote_url("origin") else {
+        return;
+    };
 
-    // 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));
+    if let Ok(gitlab_self_hosted) = Gitlab::from_remote_url(&origin_url) {
+        provider_registry.register_hosting_provider(Arc::new(gitlab_self_hosted));
+    }
 }

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

@@ -1,16 +1,55 @@
+use anyhow::{anyhow, bail, Result};
 use url::Url;
+use util::maybe;
 
 use git::{BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote};
 
-pub struct Gitlab;
+#[derive(Debug)]
+pub struct Gitlab {
+    name: String,
+    base_url: Url,
+}
+
+impl Gitlab {
+    pub fn new() -> Self {
+        Self {
+            name: "GitLab".to_string(),
+            base_url: Url::parse("https://gitlab.com").unwrap(),
+        }
+    }
+
+    pub fn from_remote_url(remote_url: &str) -> Result<Self> {
+        let host = maybe!({
+            if let Some(remote_url) = remote_url.strip_prefix("git@") {
+                if let Some((host, _)) = remote_url.trim_start_matches("git@").split_once(':') {
+                    return Some(host.to_string());
+                }
+            }
+
+            Url::parse(&remote_url)
+                .ok()
+                .and_then(|remote_url| remote_url.host_str().map(|host| host.to_string()))
+        })
+        .ok_or_else(|| anyhow!("URL has no host"))?;
+
+        if !host.contains("gitlab") {
+            bail!("not a GitLab URL");
+        }
+
+        Ok(Self {
+            name: "GitLab Self-Hosted".to_string(),
+            base_url: Url::parse(&format!("https://{}", host))?,
+        })
+    }
+}
 
 impl GitHostingProvider for Gitlab {
     fn name(&self) -> String {
-        "GitLab".to_string()
+        self.name.clone()
     }
 
     fn base_url(&self) -> Url {
-        Url::parse("https://gitlab.com").unwrap()
+        self.base_url.clone()
     }
 
     fn supports_avatars(&self) -> bool {
@@ -26,10 +65,12 @@ impl GitHostingProvider for Gitlab {
     }
 
     fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
-        if url.starts_with("git@gitlab.com:") || url.starts_with("https://gitlab.com/") {
+        let host = self.base_url.host_str()?;
+
+        if url.starts_with(&format!("git@{host}")) || url.starts_with(&format!("https://{host}/")) {
             let repo_with_owner = url
-                .trim_start_matches("git@gitlab.com:")
-                .trim_start_matches("https://gitlab.com/")
+                .trim_start_matches(&format!("git@{host}:"))
+                .trim_start_matches(&format!("https://{host}/"))
                 .trim_end_matches(".git");
 
             let (owner, repo) = repo_with_owner.split_once('/')?;
@@ -79,6 +120,8 @@ impl GitHostingProvider for Gitlab {
 
 #[cfg(test)]
 mod tests {
+    use pretty_assertions::assert_eq;
+
     use super::*;
 
     #[test]
@@ -87,7 +130,7 @@ mod tests {
             owner: "zed-industries",
             repo: "zed",
         };
-        let permalink = Gitlab.build_permalink(
+        let permalink = Gitlab::new().build_permalink(
             remote,
             BuildPermalinkParams {
                 sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
@@ -106,7 +149,7 @@ mod tests {
             owner: "zed-industries",
             repo: "zed",
         };
-        let permalink = Gitlab.build_permalink(
+        let permalink = Gitlab::new().build_permalink(
             remote,
             BuildPermalinkParams {
                 sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
@@ -125,7 +168,7 @@ mod tests {
             owner: "zed-industries",
             repo: "zed",
         };
-        let permalink = Gitlab.build_permalink(
+        let permalink = Gitlab::new().build_permalink(
             remote,
             BuildPermalinkParams {
                 sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
@@ -144,7 +187,7 @@ mod tests {
             owner: "zed-industries",
             repo: "zed",
         };
-        let permalink = Gitlab.build_permalink(
+        let permalink = Gitlab::new().build_permalink(
             remote,
             BuildPermalinkParams {
                 sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
@@ -163,7 +206,7 @@ mod tests {
             owner: "zed-industries",
             repo: "zed",
         };
-        let permalink = Gitlab.build_permalink(
+        let permalink = Gitlab::new().build_permalink(
             remote,
             BuildPermalinkParams {
                 sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
@@ -182,7 +225,7 @@ mod tests {
             owner: "zed-industries",
             repo: "zed",
         };
-        let permalink = Gitlab.build_permalink(
+        let permalink = Gitlab::new().build_permalink(
             remote,
             BuildPermalinkParams {
                 sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
@@ -194,4 +237,48 @@ mod tests {
         let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-48";
         assert_eq!(permalink.to_string(), expected_url.to_string())
     }
+
+    #[test]
+    fn test_build_gitlab_self_hosted_permalink_from_ssh_url() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let gitlab =
+            Gitlab::from_remote_url("git@gitlab.some-enterprise.com:zed-industries/zed.git")
+                .unwrap();
+        let permalink = gitlab.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: None,
+            },
+        );
+
+        let expected_url = "https://gitlab.some-enterprise.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_gitlab_self_hosted_permalink_from_https_url() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let gitlab =
+            Gitlab::from_remote_url("https://gitlab-instance.big-co.com/zed-industries/zed.git")
+                .unwrap();
+        let permalink = gitlab.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
+                path: "crates/zed/src/main.rs",
+                selection: None,
+            },
+        );
+
+        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())
+    }
 }

crates/worktree/Cargo.toml 🔗

@@ -29,6 +29,7 @@ fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
 git.workspace = true
+git_hosting_providers.workspace = true
 gpui.workspace = true
 ignore.workspace = true
 language.workspace = true

crates/worktree/src/worktree.rs 🔗

@@ -19,6 +19,7 @@ use futures::{
     FutureExt as _, Stream, StreamExt,
 };
 use fuzzy::CharBag;
+use git::GitHostingProviderRegistry;
 use git::{
     repository::{GitFileStatus, GitRepository, RepoPath},
     status::GitStatus,
@@ -299,6 +300,7 @@ struct BackgroundScannerState {
     removed_entries: HashMap<u64, Entry>,
     changed_paths: Vec<Arc<Path>>,
     prev_snapshot: Snapshot,
+    git_hosting_provider_registry: Option<Arc<GitHostingProviderRegistry>>,
 }
 
 #[derive(Debug, Clone)]
@@ -1004,6 +1006,7 @@ impl LocalWorktree {
         let share_private_files = self.share_private_files;
         let next_entry_id = self.next_entry_id.clone();
         let fs = self.fs.clone();
+        let git_hosting_provider_registry = GitHostingProviderRegistry::try_global(cx);
         let settings = self.settings.clone();
         let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded();
         let background_scanner = cx.background_executor().spawn({
@@ -1039,6 +1042,7 @@ impl LocalWorktree {
                         paths_to_scan: Default::default(),
                         removed_entries: Default::default(),
                         changed_paths: Default::default(),
+                        git_hosting_provider_registry,
                     }),
                     phase: BackgroundScannerPhase::InitialScan,
                     share_private_files,
@@ -2948,6 +2952,13 @@ impl BackgroundScannerState {
         log::trace!("constructed libgit2 repo in {:?}", t0.elapsed());
         let work_directory = RepositoryWorkDirectory(work_dir_path.clone());
 
+        if let Some(git_hosting_provider_registry) = self.git_hosting_provider_registry.clone() {
+            git_hosting_providers::register_additional_providers(
+                git_hosting_provider_registry,
+                repository.clone(),
+            );
+        }
+
         self.snapshot.repository_entries.insert(
             work_directory.clone(),
             RepositoryEntry {