From 322aa41ad60e5f4dcd6ddf420169ef76cee5eadd Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 29 Oct 2024 12:31:51 -0400 Subject: [PATCH] Add support for self-hosted GitLab instances for Git permalinks (#19909) 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. --- Cargo.lock | 2 + crates/git/src/hosting_provider.rs | 6 + crates/git_hosting_providers/Cargo.toml | 1 + .../src/git_hosting_providers.rs | 33 ++++-- .../src/providers/gitlab.rs | 111 ++++++++++++++++-- crates/worktree/Cargo.toml | 1 + crates/worktree/src/worktree.rs | 11 ++ 7 files changed, 142 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b3c4de81d..c04ec535a6 100644 --- a/Cargo.lock +++ b/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", diff --git a/crates/git/src/hosting_provider.rs b/crates/git/src/hosting_provider.rs index 988dae377f..72ed92e8ab 100644 --- a/crates/git/src/hosting_provider.rs +++ b/crates/git/src/hosting_provider.rs @@ -111,6 +111,12 @@ impl GitHostingProviderRegistry { cx.global::().0.clone() } + /// Returns the global [`GitHostingProviderRegistry`], if one is set. + pub fn try_global(cx: &AppContext) -> Option> { + cx.try_global::() + .map(|registry| registry.0.clone()) + } + /// Returns the global [`GitHostingProviderRegistry`]. /// /// Inserts a default [`GitHostingProviderRegistry`] if one does not yet exist. diff --git a/crates/git_hosting_providers/Cargo.toml b/crates/git_hosting_providers/Cargo.toml index b8ad1ed05d..eac30b72d9 100644 --- a/crates/git_hosting_providers/Cargo.toml +++ b/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 diff --git a/crates/git_hosting_providers/src/git_hosting_providers.rs b/crates/git_hosting_providers/src/git_hosting_providers.rs index 864faa9b49..2689d797f4 100644 --- a/crates/git_hosting_providers/src/git_hosting_providers.rs +++ b/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(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)); + 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)); +} + +/// 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, + repository: Arc, +) { + let Some(origin_url) = repository.remote_url("origin") else { + return; + }; + + if let Ok(gitlab_self_hosted) = Gitlab::from_remote_url(&origin_url) { + provider_registry.register_hosting_provider(Arc::new(gitlab_self_hosted)); + } } diff --git a/crates/git_hosting_providers/src/providers/gitlab.rs b/crates/git_hosting_providers/src/providers/gitlab.rs index 36ee214cf9..a8b97182c0 100644 --- a/crates/git_hosting_providers/src/providers/gitlab.rs +++ b/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 { + 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> { - 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()) + } } diff --git a/crates/worktree/Cargo.toml b/crates/worktree/Cargo.toml index 9437358e1a..da3676f15c 100644 --- a/crates/worktree/Cargo.toml +++ b/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 diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index ba65eae87c..8114f2dd7b 100644 --- a/crates/worktree/src/worktree.rs +++ b/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, changed_paths: Vec>, prev_snapshot: Snapshot, + git_hosting_provider_registry: Option>, } #[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 {