Refactor Git hosting providers (#11457)

Marshall Bowers created

This PR refactors the code pertaining to Git hosting providers to make
it more uniform and easy to add support for new providers.

There is now a `GitHostingProvider` trait that contains the
functionality specific to an individual Git hosting provider. Each
provider we support has an implementation of this trait.

Release Notes:

- N/A

Change summary

Cargo.lock                                    |   1 
crates/editor/src/blame_entry_tooltip.rs      |   2 
crates/editor/src/editor.rs                   |  29 
crates/editor/src/git/blame.rs                |  41 
crates/git/Cargo.toml                         |   1 
crates/git/src/blame.rs                       |  13 
crates/git/src/git.rs                         |   9 
crates/git/src/hosting_provider.rs            | 170 ++--
crates/git/src/hosting_providers.rs           |  13 
crates/git/src/hosting_providers/bitbucket.rs | 169 +++++
crates/git/src/hosting_providers/codeberg.rs  | 219 ++++++
crates/git/src/hosting_providers/gitee.rs     | 196 ++++++
crates/git/src/hosting_providers/github.rs    | 287 ++++++++
crates/git/src/hosting_providers/gitlab.rs    | 196 ++++++
crates/git/src/hosting_providers/sourcehut.rs | 217 ++++++
crates/git/src/permalink.rs                   | 680 ---------------------
crates/git/src/pull_request.rs                |  83 --
17 files changed, 1,443 insertions(+), 883 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4384,6 +4384,7 @@ name = "git"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "async-trait",
  "clock",
  "collections",
  "git2",

crates/editor/src/blame_entry_tooltip.rs 🔗

@@ -61,7 +61,7 @@ struct CommitAvatarAsset {
 impl Hash for CommitAvatarAsset {
     fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
         self.sha.hash(state);
-        self.remote.host.hash(state);
+        self.remote.host.name().hash(state);
     }
 }
 

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::permalink::{build_permalink, BuildPermalinkParams};
+use ::git::{parse_git_remote_url, BuildPermalinkParams};
 pub(crate) use actions::*;
 use aho_corasick::AhoCorasick;
 use anyhow::{anyhow, Context as _, Result};
@@ -9548,17 +9548,22 @@ impl Editor {
         let selections = self.selections.all::<Point>(cx);
         let selection = selections.iter().peekable().next();
 
-        build_permalink(BuildPermalinkParams {
-            remote_url: &origin_url,
-            sha: &sha,
-            path: &path,
-            selection: selection.map(|selection| {
-                let range = selection.range();
-                let start = range.start.row;
-                let end = range.end.row;
-                start..end
-            }),
-        })
+        let (provider, remote) = parse_git_remote_url(&origin_url)
+            .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
+
+        Ok(provider.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: &sha,
+                path: &path,
+                selection: selection.map(|selection| {
+                    let range = selection.range();
+                    let start = range.start.row;
+                    let end = range.end.row;
+                    start..end
+                }),
+            },
+        ))
     }
 
     pub fn copy_permalink_to_line(&mut self, _: &CopyPermalinkToLine, cx: &mut ViewContext<Self>) {

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

@@ -4,10 +4,7 @@ use anyhow::Result;
 use collections::HashMap;
 use git::{
     blame::{Blame, BlameEntry},
-    hosting_provider::HostingProvider,
-    permalink::{build_commit_permalink, parse_git_remote_url},
-    pull_request::{extract_pull_request, PullRequest},
-    Oid,
+    parse_git_remote_url, GitHostingProvider, Oid, PullRequest,
 };
 use gpui::{Model, ModelContext, Subscription, Task};
 use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown};
@@ -50,13 +47,23 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 {
     }
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone)]
 pub struct GitRemote {
-    pub host: HostingProvider,
+    pub host: Arc<dyn GitHostingProvider + Send + Sync + 'static>,
     pub owner: String,
     pub repo: String,
 }
 
+impl std::fmt::Debug for GitRemote {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("GitRemote")
+            .field("host", &self.host.name())
+            .field("owner", &self.owner)
+            .field("repo", &self.repo)
+            .finish()
+    }
+}
+
 impl GitRemote {
     pub fn host_supports_avatars(&self) -> bool {
         self.host.supports_avatars()
@@ -440,10 +447,10 @@ async fn parse_commit_messages(
     for (oid, message) in messages {
         let parsed_message = parse_markdown(&message, &languages).await;
 
-        let permalink = if let Some(git_remote) = parsed_remote_url.as_ref() {
-            Some(build_commit_permalink(
-                git::permalink::BuildCommitPermalinkParams {
-                    remote: git_remote,
+        let permalink = if let Some((provider, git_remote)) = parsed_remote_url.as_ref() {
+            Some(provider.build_commit_permalink(
+                git_remote,
+                git::BuildCommitPermalinkParams {
                     sha: oid.to_string().as_str(),
                 },
             ))
@@ -455,15 +462,17 @@ async fn parse_commit_messages(
             deprecated_permalinks.get(&oid).cloned()
         };
 
-        let remote = parsed_remote_url.as_ref().map(|remote| GitRemote {
-            host: remote.provider.clone(),
-            owner: remote.owner.to_string(),
-            repo: remote.repo.to_string(),
-        });
+        let remote = parsed_remote_url
+            .as_ref()
+            .map(|(provider, remote)| GitRemote {
+                host: provider.clone(),
+                owner: remote.owner.to_string(),
+                repo: remote.repo.to_string(),
+            });
 
         let pull_request = parsed_remote_url
             .as_ref()
-            .and_then(|remote| extract_pull_request(remote, &message));
+            .and_then(|(provider, remote)| provider.extract_pull_request(remote, &message));
 
         commit_details.insert(
             oid,

crates/git/Cargo.toml 🔗

@@ -13,6 +13,7 @@ path = "src/git.rs"
 
 [dependencies]
 anyhow.workspace = true
+async-trait.workspace = true
 clock.workspace = true
 collections.workspace = true
 git2.workspace = true

crates/git/src/blame.rs 🔗

@@ -1,6 +1,5 @@
 use crate::commit::get_messages;
-use crate::permalink::{build_commit_permalink, parse_git_remote_url, BuildCommitPermalinkParams};
-use crate::Oid;
+use crate::{parse_git_remote_url, BuildCommitPermalinkParams, Oid};
 use anyhow::{anyhow, Context, Result};
 use collections::{HashMap, HashSet};
 use serde::{Deserialize, Serialize};
@@ -47,12 +46,14 @@ impl Blame {
             unique_shas.insert(entry.sha);
             // DEPRECATED (18 Apr 24): Sending permalinks over the wire is deprecated. Clients
             // now do the parsing.
-            if let Some(remote) = parsed_remote_url.as_ref() {
+            if let Some((provider, remote)) = parsed_remote_url.as_ref() {
                 permalinks.entry(entry.sha).or_insert_with(|| {
-                    build_commit_permalink(BuildCommitPermalinkParams {
+                    provider.build_commit_permalink(
                         remote,
-                        sha: entry.sha.to_string().as_str(),
-                    })
+                        BuildCommitPermalinkParams {
+                            sha: entry.sha.to_string().as_str(),
+                        },
+                    )
                 });
             }
         }

crates/git/src/git.rs 🔗

@@ -1,3 +1,6 @@
+mod hosting_provider;
+mod hosting_providers;
+
 use anyhow::{anyhow, Context, Result};
 use serde::{Deserialize, Serialize};
 use std::ffi::OsStr;
@@ -7,12 +10,12 @@ use std::str::FromStr;
 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;
 pub mod diff;
-pub mod hosting_provider;
-pub mod permalink;
-pub mod pull_request;
 pub mod repository;
 
 lazy_static! {

crates/git/src/hosting_provider.rs 🔗

@@ -1,110 +1,116 @@
-use core::fmt;
 use std::{ops::Range, sync::Arc};
 
 use anyhow::Result;
+use async_trait::async_trait;
 use url::Url;
-use util::{codeberg, github, http::HttpClient};
+use util::http::HttpClient;
 
+use crate::hosting_providers::{Bitbucket, Codeberg, Gitee, Github, Gitlab, Sourcehut};
 use crate::Oid;
 
-#[derive(Clone, Debug, Hash)]
-pub enum HostingProvider {
-    Github,
-    Gitlab,
-    Gitee,
-    Bitbucket,
-    Sourcehut,
-    Codeberg,
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct PullRequest {
+    pub number: u32,
+    pub url: Url,
 }
 
-impl HostingProvider {
-    pub(crate) fn base_url(&self) -> Url {
-        let base_url = match self {
-            Self::Github => "https://github.com",
-            Self::Gitlab => "https://gitlab.com",
-            Self::Gitee => "https://gitee.com",
-            Self::Bitbucket => "https://bitbucket.org",
-            Self::Sourcehut => "https://git.sr.ht",
-            Self::Codeberg => "https://codeberg.org",
-        };
-
-        Url::parse(&base_url).unwrap()
-    }
+pub struct BuildCommitPermalinkParams<'a> {
+    pub sha: &'a str,
+}
+
+pub struct BuildPermalinkParams<'a> {
+    pub sha: &'a str,
+    pub path: &'a str,
+    pub selection: Option<Range<u32>>,
+}
+
+/// A Git hosting provider.
+#[async_trait]
+pub trait GitHostingProvider {
+    /// Returns the name of the provider.
+    fn name(&self) -> String;
+
+    /// Returns the base URL of the provider.
+    fn base_url(&self) -> Url;
+
+    /// Returns a permalink to a Git commit on this hosting provider.
+    fn build_commit_permalink(
+        &self,
+        remote: &ParsedGitRemote,
+        params: BuildCommitPermalinkParams,
+    ) -> Url;
+
+    /// Returns a permalink to a file and/or selection on this hosting provider.
+    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url;
+
+    /// Returns whether this provider supports avatars.
+    fn supports_avatars(&self) -> bool;
 
-    /// Returns the fragment portion of the URL for the selected lines in
-    /// the representation the [`GitHostingProvider`] expects.
-    pub(crate) fn line_fragment(&self, selection: &Range<u32>) -> String {
+    /// Returns a URL fragment to the given line selection.
+    fn line_fragment(&self, selection: &Range<u32>) -> String {
         if selection.start == selection.end {
             let line = selection.start + 1;
 
-            match self {
-                Self::Github | Self::Gitlab | Self::Gitee | Self::Sourcehut | Self::Codeberg => {
-                    format!("L{}", line)
-                }
-                Self::Bitbucket => format!("lines-{}", line),
-            }
+            self.format_line_number(line)
         } else {
             let start_line = selection.start + 1;
             let end_line = selection.end + 1;
 
-            match self {
-                Self::Github | Self::Codeberg => format!("L{}-L{}", start_line, end_line),
-                Self::Gitlab | Self::Gitee | Self::Sourcehut => {
-                    format!("L{}-{}", start_line, end_line)
-                }
-                Self::Bitbucket => format!("lines-{}:{}", start_line, end_line),
-            }
+            self.format_line_numbers(start_line, end_line)
         }
     }
 
-    pub fn supports_avatars(&self) -> bool {
-        match self {
-            HostingProvider::Github | HostingProvider::Codeberg => true,
-            _ => false,
-        }
+    /// Returns a formatted line number to be placed in a permalink URL.
+    fn format_line_number(&self, line: u32) -> String;
+
+    /// Returns a formatted range of line numbers to be placed in a permalink URL.
+    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String;
+
+    fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>>;
+
+    fn extract_pull_request(
+        &self,
+        _remote: &ParsedGitRemote,
+        _message: &str,
+    ) -> Option<PullRequest> {
+        None
     }
 
-    pub async fn commit_author_avatar_url(
+    async fn commit_author_avatar_url(
         &self,
-        repo_owner: &str,
-        repo: &str,
-        commit: Oid,
-        client: Arc<dyn HttpClient>,
+        _repo_owner: &str,
+        _repo: &str,
+        _commit: Oid,
+        _http_client: Arc<dyn HttpClient>,
     ) -> Result<Option<Url>> {
-        Ok(match self {
-            HostingProvider::Github => {
-                let commit = commit.to_string();
-                github::fetch_github_commit_author(repo_owner, repo, &commit, &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()
-            }
-            HostingProvider::Codeberg => {
-                let commit = commit.to_string();
-                codeberg::fetch_codeberg_commit_author(repo_owner, repo, &commit, &client)
-                    .await?
-                    .map(|author| Url::parse(&author.avatar_url))
-                    .transpose()
-            }
-            _ => Ok(None),
-        }?)
+        Ok(None)
     }
 }
 
-impl fmt::Display for HostingProvider {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        let name = match self {
-            HostingProvider::Github => "GitHub",
-            HostingProvider::Gitlab => "GitLab",
-            HostingProvider::Gitee => "Gitee",
-            HostingProvider::Bitbucket => "Bitbucket",
-            HostingProvider::Sourcehut => "Sourcehut",
-            HostingProvider::Codeberg => "Codeberg",
-        };
-        write!(f, "{}", name)
-    }
+#[derive(Debug)]
+pub struct ParsedGitRemote<'a> {
+    pub owner: &'a str,
+    pub repo: &'a str,
+}
+
+pub fn parse_git_remote_url(
+    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))
+    })
 }

crates/git/src/hosting_providers.rs 🔗

@@ -0,0 +1,13 @@
+mod bitbucket;
+mod codeberg;
+mod gitee;
+mod github;
+mod gitlab;
+mod sourcehut;
+
+pub use bitbucket::*;
+pub use codeberg::*;
+pub use gitee::*;
+pub use github::*;
+pub use gitlab::*;
+pub use sourcehut::*;

crates/git/src/hosting_providers/bitbucket.rs 🔗

@@ -0,0 +1,169 @@
+use url::Url;
+
+use crate::{
+    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
+};
+
+pub struct Bitbucket;
+
+impl GitHostingProvider for Bitbucket {
+    fn name(&self) -> String {
+        "Bitbucket".to_string()
+    }
+
+    fn base_url(&self) -> Url {
+        Url::parse("https://bitbucket.org").unwrap()
+    }
+
+    fn supports_avatars(&self) -> bool {
+        false
+    }
+
+    fn format_line_number(&self, line: u32) -> String {
+        format!("lines-{line}")
+    }
+
+    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
+        format!("lines-{start_line}:{end_line}")
+    }
+
+    fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
+        if url.contains("bitbucket.org") {
+            let (_, repo_with_owner) = url.trim_end_matches(".git").split_once("bitbucket.org")?;
+            let (owner, repo) = repo_with_owner
+                .trim_start_matches('/')
+                .trim_start_matches(':')
+                .split_once('/')?;
+
+            return Some(ParsedGitRemote { owner, repo });
+        }
+
+        None
+    }
+
+    fn build_commit_permalink(
+        &self,
+        remote: &ParsedGitRemote,
+        params: BuildCommitPermalinkParams,
+    ) -> Url {
+        let BuildCommitPermalinkParams { sha } = params;
+        let ParsedGitRemote { owner, repo } = remote;
+
+        self.base_url()
+            .join(&format!("{owner}/{repo}/commits/{sha}"))
+            .unwrap()
+    }
+
+    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
+        let ParsedGitRemote { owner, repo } = remote;
+        let BuildPermalinkParams {
+            sha,
+            path,
+            selection,
+        } = params;
+
+        let mut permalink = self
+            .base_url()
+            .join(&format!("{owner}/{repo}/src/{sha}/{path}"))
+            .unwrap();
+        permalink.set_fragment(
+            selection
+                .map(|selection| self.line_fragment(&selection))
+                .as_deref(),
+        );
+        permalink
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::parse_git_remote_url;
+
+    use super::*;
+
+    #[test]
+    fn test_parse_git_remote_url_bitbucket_https_with_username() {
+        let url = "https://thorstenballzed@bitbucket.org/thorstenzed/testingrepo.git";
+        let (provider, parsed) = parse_git_remote_url(url).unwrap();
+        assert_eq!(provider.name(), "Bitbucket");
+        assert_eq!(parsed.owner, "thorstenzed");
+        assert_eq!(parsed.repo, "testingrepo");
+    }
+
+    #[test]
+    fn test_parse_git_remote_url_bitbucket_https_without_username() {
+        let url = "https://bitbucket.org/thorstenzed/testingrepo.git";
+        let (provider, parsed) = parse_git_remote_url(url).unwrap();
+        assert_eq!(provider.name(), "Bitbucket");
+        assert_eq!(parsed.owner, "thorstenzed");
+        assert_eq!(parsed.repo, "testingrepo");
+    }
+
+    #[test]
+    fn test_parse_git_remote_url_bitbucket_git() {
+        let url = "git@bitbucket.org:thorstenzed/testingrepo.git";
+        let (provider, parsed) = parse_git_remote_url(url).unwrap();
+        assert_eq!(provider.name(), "Bitbucket");
+        assert_eq!(parsed.owner, "thorstenzed");
+        assert_eq!(parsed.repo, "testingrepo");
+    }
+
+    #[test]
+    fn test_build_bitbucket_permalink_from_ssh_url() {
+        let remote = ParsedGitRemote {
+            owner: "thorstenzed",
+            repo: "testingrepo",
+        };
+        let permalink = Bitbucket.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "f00b4r",
+                path: "main.rs",
+                selection: None,
+            },
+        );
+
+        let expected_url = "https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_bitbucket_permalink_from_ssh_url_single_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "thorstenzed",
+            repo: "testingrepo",
+        };
+        let permalink = Bitbucket.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "f00b4r",
+                path: "main.rs",
+                selection: Some(6..6),
+            },
+        );
+
+        let expected_url =
+            "https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs#lines-7";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_bitbucket_permalink_from_ssh_url_multi_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "thorstenzed",
+            repo: "testingrepo",
+        };
+        let permalink = Bitbucket.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "f00b4r",
+                path: "main.rs",
+                selection: Some(23..47),
+            },
+        );
+
+        let expected_url =
+            "https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs#lines-24:48";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+}

crates/git/src/hosting_providers/codeberg.rs 🔗

@@ -0,0 +1,219 @@
+use std::sync::Arc;
+
+use anyhow::Result;
+use async_trait::async_trait;
+use url::Url;
+use util::codeberg;
+use util::http::HttpClient;
+
+use crate::{
+    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
+};
+
+pub struct Codeberg;
+
+#[async_trait]
+impl GitHostingProvider for Codeberg {
+    fn name(&self) -> String {
+        "Codeberg".to_string()
+    }
+
+    fn base_url(&self) -> Url {
+        Url::parse("https://codeberg.org").unwrap()
+    }
+
+    fn supports_avatars(&self) -> bool {
+        true
+    }
+
+    fn format_line_number(&self, line: u32) -> String {
+        format!("L{line}")
+    }
+
+    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
+        format!("L{start_line}-L{end_line}")
+    }
+
+    fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
+        if url.starts_with("git@codeberg.org:") || url.starts_with("https://codeberg.org/") {
+            let repo_with_owner = url
+                .trim_start_matches("git@codeberg.org:")
+                .trim_start_matches("https://codeberg.org/")
+                .trim_end_matches(".git");
+
+            let (owner, repo) = repo_with_owner.split_once('/')?;
+
+            return Some(ParsedGitRemote { owner, repo });
+        }
+
+        None
+    }
+
+    fn build_commit_permalink(
+        &self,
+        remote: &ParsedGitRemote,
+        params: BuildCommitPermalinkParams,
+    ) -> Url {
+        let BuildCommitPermalinkParams { sha } = params;
+        let ParsedGitRemote { owner, repo } = remote;
+
+        self.base_url()
+            .join(&format!("{owner}/{repo}/commit/{sha}"))
+            .unwrap()
+    }
+
+    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
+        let ParsedGitRemote { owner, repo } = remote;
+        let BuildPermalinkParams {
+            sha,
+            path,
+            selection,
+        } = params;
+
+        let mut permalink = self
+            .base_url()
+            .join(&format!("{owner}/{repo}/src/commit/{sha}/{path}"))
+            .unwrap();
+        permalink.set_fragment(
+            selection
+                .map(|selection| self.line_fragment(&selection))
+                .as_deref(),
+        );
+        permalink
+    }
+
+    async fn commit_author_avatar_url(
+        &self,
+        repo_owner: &str,
+        repo: &str,
+        commit: Oid,
+        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()?;
+        Ok(avatar_url)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_build_codeberg_permalink_from_ssh_url() {
+        let remote = ParsedGitRemote {
+            owner: "rajveermalviya",
+            repo: "zed",
+        };
+        let permalink = Codeberg.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: None,
+            },
+        );
+
+        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_codeberg_permalink_from_ssh_url_single_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "rajveermalviya",
+            repo: "zed",
+        };
+        let permalink = Codeberg.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: Some(6..6),
+            },
+        );
+
+        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_codeberg_permalink_from_ssh_url_multi_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "rajveermalviya",
+            repo: "zed",
+        };
+        let permalink = Codeberg.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: Some(23..47),
+            },
+        );
+
+        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_codeberg_permalink_from_https_url() {
+        let remote = ParsedGitRemote {
+            owner: "rajveermalviya",
+            repo: "zed",
+        };
+        let permalink = Codeberg.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
+                path: "crates/zed/src/main.rs",
+                selection: None,
+            },
+        );
+
+        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_codeberg_permalink_from_https_url_single_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "rajveermalviya",
+            repo: "zed",
+        };
+        let permalink = Codeberg.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
+                path: "crates/zed/src/main.rs",
+                selection: Some(6..6),
+            },
+        );
+
+        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs#L7";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_codeberg_permalink_from_https_url_multi_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "rajveermalviya",
+            repo: "zed",
+        };
+        let permalink = Codeberg.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
+                path: "crates/zed/src/main.rs",
+                selection: Some(23..47),
+            },
+        );
+
+        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs#L24-L48";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+}

crates/git/src/hosting_providers/gitee.rs 🔗

@@ -0,0 +1,196 @@
+use url::Url;
+
+use crate::{
+    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
+};
+
+pub struct Gitee;
+
+impl GitHostingProvider for Gitee {
+    fn name(&self) -> String {
+        "Gitee".to_string()
+    }
+
+    fn base_url(&self) -> Url {
+        Url::parse("https://gitee.com").unwrap()
+    }
+
+    fn supports_avatars(&self) -> bool {
+        false
+    }
+
+    fn format_line_number(&self, line: u32) -> String {
+        format!("L{line}")
+    }
+
+    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
+        format!("L{start_line}-{end_line}")
+    }
+
+    fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
+        if url.starts_with("git@gitee.com:") || url.starts_with("https://gitee.com/") {
+            let repo_with_owner = url
+                .trim_start_matches("git@gitee.com:")
+                .trim_start_matches("https://gitee.com/")
+                .trim_end_matches(".git");
+
+            let (owner, repo) = repo_with_owner.split_once('/')?;
+
+            return Some(ParsedGitRemote { owner, repo });
+        }
+
+        None
+    }
+
+    fn build_commit_permalink(
+        &self,
+        remote: &ParsedGitRemote,
+        params: BuildCommitPermalinkParams,
+    ) -> Url {
+        let BuildCommitPermalinkParams { sha } = params;
+        let ParsedGitRemote { owner, repo } = remote;
+
+        self.base_url()
+            .join(&format!("{owner}/{repo}/commit/{sha}"))
+            .unwrap()
+    }
+
+    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
+        let ParsedGitRemote { owner, repo } = remote;
+        let BuildPermalinkParams {
+            sha,
+            path,
+            selection,
+        } = params;
+
+        let mut permalink = self
+            .base_url()
+            .join(&format!("{owner}/{repo}/blob/{sha}/{path}"))
+            .unwrap();
+        permalink.set_fragment(
+            selection
+                .map(|selection| self.line_fragment(&selection))
+                .as_deref(),
+        );
+        permalink
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_build_gitee_permalink_from_ssh_url() {
+        let remote = ParsedGitRemote {
+            owner: "libkitten",
+            repo: "zed",
+        };
+        let permalink = Gitee.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: None,
+            },
+        );
+
+        let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_gitee_permalink_from_ssh_url_single_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "libkitten",
+            repo: "zed",
+        };
+        let permalink = Gitee.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: Some(6..6),
+            },
+        );
+
+        let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L7";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_gitee_permalink_from_ssh_url_multi_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "libkitten",
+            repo: "zed",
+        };
+        let permalink = Gitee.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: Some(23..47),
+            },
+        );
+
+        let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L24-48";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_gitee_permalink_from_https_url() {
+        let remote = ParsedGitRemote {
+            owner: "libkitten",
+            repo: "zed",
+        };
+        let permalink = Gitee.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
+                path: "crates/zed/src/main.rs",
+                selection: None,
+            },
+        );
+
+        let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_gitee_permalink_from_https_url_single_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "libkitten",
+            repo: "zed",
+        };
+        let permalink = Gitee.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
+                path: "crates/zed/src/main.rs",
+                selection: Some(6..6),
+            },
+        );
+
+        let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs#L7";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_gitee_permalink_from_https_url_multi_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "libkitten",
+            repo: "zed",
+        };
+        let permalink = Gitee.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
+                path: "crates/zed/src/main.rs",
+                selection: Some(23..47),
+            },
+        );
+
+        let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs#L24-48";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+}

crates/git/src/hosting_providers/github.rs 🔗

@@ -0,0 +1,287 @@
+use std::sync::{Arc, OnceLock};
+
+use anyhow::Result;
+use async_trait::async_trait;
+use regex::Regex;
+use url::Url;
+use util::github;
+use util::http::HttpClient;
+
+use crate::{
+    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
+    PullRequest,
+};
+
+fn pull_request_number_regex() -> &'static Regex {
+    static PULL_REQUEST_NUMBER_REGEX: OnceLock<Regex> = OnceLock::new();
+
+    PULL_REQUEST_NUMBER_REGEX.get_or_init(|| Regex::new(r"\(#(\d+)\)$").unwrap())
+}
+
+pub struct Github;
+
+#[async_trait]
+impl GitHostingProvider for Github {
+    fn name(&self) -> String {
+        "GitHub".to_string()
+    }
+
+    fn base_url(&self) -> Url {
+        Url::parse("https://github.com").unwrap()
+    }
+
+    fn supports_avatars(&self) -> bool {
+        true
+    }
+
+    fn format_line_number(&self, line: u32) -> String {
+        format!("L{line}")
+    }
+
+    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
+        format!("L{start_line}-L{end_line}")
+    }
+
+    fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
+        if url.starts_with("git@github.com:") || url.starts_with("https://github.com/") {
+            let repo_with_owner = url
+                .trim_start_matches("git@github.com:")
+                .trim_start_matches("https://github.com/")
+                .trim_end_matches(".git");
+
+            let (owner, repo) = repo_with_owner.split_once('/')?;
+
+            return Some(ParsedGitRemote { owner, repo });
+        }
+
+        None
+    }
+
+    fn build_commit_permalink(
+        &self,
+        remote: &ParsedGitRemote,
+        params: BuildCommitPermalinkParams,
+    ) -> Url {
+        let BuildCommitPermalinkParams { sha } = params;
+        let ParsedGitRemote { owner, repo } = remote;
+
+        self.base_url()
+            .join(&format!("{owner}/{repo}/commit/{sha}"))
+            .unwrap()
+    }
+
+    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
+        let ParsedGitRemote { owner, repo } = remote;
+        let BuildPermalinkParams {
+            sha,
+            path,
+            selection,
+        } = params;
+
+        let mut permalink = self
+            .base_url()
+            .join(&format!("{owner}/{repo}/blob/{sha}/{path}"))
+            .unwrap();
+        permalink.set_fragment(
+            selection
+                .map(|selection| self.line_fragment(&selection))
+                .as_deref(),
+        );
+        permalink
+    }
+
+    fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
+        let line = message.lines().next()?;
+        let capture = pull_request_number_regex().captures(line)?;
+        let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
+
+        let mut url = self.base_url();
+        let path = format!("/{}/{}/pull/{}", remote.owner, remote.repo, number);
+        url.set_path(&path);
+
+        Some(PullRequest { number, url })
+    }
+
+    async fn commit_author_avatar_url(
+        &self,
+        repo_owner: &str,
+        repo: &str,
+        commit: Oid,
+        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()?;
+        Ok(avatar_url)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    // TODO: Replace with `indoc`.
+    use unindent::Unindent;
+
+    use super::*;
+
+    #[test]
+    fn test_build_github_permalink_from_ssh_url() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let permalink = Github.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: None,
+            },
+        );
+
+        let expected_url = "https://github.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_github_permalink_from_ssh_url_single_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let permalink = Github.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: Some(6..6),
+            },
+        );
+
+        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_github_permalink_from_ssh_url_multi_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let permalink = Github.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: Some(23..47),
+            },
+        );
+
+        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_github_permalink_from_https_url() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let permalink = Github.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
+                path: "crates/zed/src/main.rs",
+                selection: None,
+            },
+        );
+
+        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_github_permalink_from_https_url_single_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let permalink = Github.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
+                path: "crates/zed/src/main.rs",
+                selection: Some(6..6),
+            },
+        );
+
+        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_github_permalink_from_https_url_multi_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let permalink = Github.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
+                path: "crates/zed/src/main.rs",
+                selection: Some(23..47),
+            },
+        );
+
+        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-L48";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_github_pull_requests() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+
+        let message = "This does not contain a pull request";
+        assert!(Github.extract_pull_request(&remote, message).is_none());
+
+        // Pull request number at end of first line
+        let message = r#"
+            project panel: do not expand collapsed worktrees on "collapse all entries" (#10687)
+
+            Fixes #10597
+
+            Release Notes:
+
+            - Fixed "project panel: collapse all entries" expanding collapsed worktrees.
+            "#
+        .unindent();
+
+        assert_eq!(
+            Github
+                .extract_pull_request(&remote, &message)
+                .unwrap()
+                .url
+                .as_str(),
+            "https://github.com/zed-industries/zed/pull/10687"
+        );
+
+        // Pull request number in middle of line, which we want to ignore
+        let message = r#"
+            Follow-up to #10687 to fix problems
+
+            See the original PR, this is a fix.
+            "#
+        .unindent();
+        assert_eq!(Github.extract_pull_request(&remote, &message), None);
+    }
+}

crates/git/src/hosting_providers/gitlab.rs 🔗

@@ -0,0 +1,196 @@
+use url::Url;
+
+use crate::{
+    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
+};
+
+pub struct Gitlab;
+
+impl GitHostingProvider for Gitlab {
+    fn name(&self) -> String {
+        "GitLab".to_string()
+    }
+
+    fn base_url(&self) -> Url {
+        Url::parse("https://gitlab.com").unwrap()
+    }
+
+    fn supports_avatars(&self) -> bool {
+        false
+    }
+
+    fn format_line_number(&self, line: u32) -> String {
+        format!("L{line}")
+    }
+
+    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
+        format!("L{start_line}-{end_line}")
+    }
+
+    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 repo_with_owner = url
+                .trim_start_matches("git@gitlab.com:")
+                .trim_start_matches("https://gitlab.com/")
+                .trim_end_matches(".git");
+
+            let (owner, repo) = repo_with_owner.split_once('/')?;
+
+            return Some(ParsedGitRemote { owner, repo });
+        }
+
+        None
+    }
+
+    fn build_commit_permalink(
+        &self,
+        remote: &ParsedGitRemote,
+        params: BuildCommitPermalinkParams,
+    ) -> Url {
+        let BuildCommitPermalinkParams { sha } = params;
+        let ParsedGitRemote { owner, repo } = remote;
+
+        self.base_url()
+            .join(&format!("{owner}/{repo}/-/commit/{sha}"))
+            .unwrap()
+    }
+
+    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
+        let ParsedGitRemote { owner, repo } = remote;
+        let BuildPermalinkParams {
+            sha,
+            path,
+            selection,
+        } = params;
+
+        let mut permalink = self
+            .base_url()
+            .join(&format!("{owner}/{repo}/-/blob/{sha}/{path}"))
+            .unwrap();
+        permalink.set_fragment(
+            selection
+                .map(|selection| self.line_fragment(&selection))
+                .as_deref(),
+        );
+        permalink
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_build_gitlab_permalink_from_ssh_url() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let permalink = Gitlab.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: None,
+            },
+        );
+
+        let expected_url = "https://gitlab.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_permalink_from_ssh_url_single_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let permalink = Gitlab.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: Some(6..6),
+            },
+        );
+
+        let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_gitlab_permalink_from_ssh_url_multi_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let permalink = Gitlab.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: Some(23..47),
+            },
+        );
+
+        let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-48";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_gitlab_permalink_from_https_url() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let permalink = Gitlab.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
+                path: "crates/zed/src/main.rs",
+                selection: None,
+            },
+        );
+
+        let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_gitlab_permalink_from_https_url_single_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let permalink = Gitlab.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
+                path: "crates/zed/src/main.rs",
+                selection: Some(6..6),
+            },
+        );
+
+        let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_gitlab_permalink_from_https_url_multi_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "zed-industries",
+            repo: "zed",
+        };
+        let permalink = Gitlab.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
+                path: "crates/zed/src/main.rs",
+                selection: Some(23..47),
+            },
+        );
+
+        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())
+    }
+}

crates/git/src/hosting_providers/sourcehut.rs 🔗

@@ -0,0 +1,217 @@
+use url::Url;
+
+use crate::{
+    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
+};
+
+pub struct Sourcehut;
+
+impl GitHostingProvider for Sourcehut {
+    fn name(&self) -> String {
+        "Gitee".to_string()
+    }
+
+    fn base_url(&self) -> Url {
+        Url::parse("https://git.sr.ht").unwrap()
+    }
+
+    fn supports_avatars(&self) -> bool {
+        false
+    }
+
+    fn format_line_number(&self, line: u32) -> String {
+        format!("L{line}")
+    }
+
+    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
+        format!("L{start_line}-{end_line}")
+    }
+
+    fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
+        if url.starts_with("git@git.sr.ht:") || url.starts_with("https://git.sr.ht/") {
+            // sourcehut indicates a repo with '.git' suffix as a separate repo.
+            // For example, "git@git.sr.ht:~username/repo" and "git@git.sr.ht:~username/repo.git"
+            // are two distinct repositories.
+            let repo_with_owner = url
+                .trim_start_matches("git@git.sr.ht:~")
+                .trim_start_matches("https://git.sr.ht/~");
+
+            let (owner, repo) = repo_with_owner.split_once('/')?;
+
+            return Some(ParsedGitRemote { owner, repo });
+        }
+
+        None
+    }
+
+    fn build_commit_permalink(
+        &self,
+        remote: &ParsedGitRemote,
+        params: BuildCommitPermalinkParams,
+    ) -> Url {
+        let BuildCommitPermalinkParams { sha } = params;
+        let ParsedGitRemote { owner, repo } = remote;
+
+        self.base_url()
+            .join(&format!("~{owner}/{repo}/commit/{sha}"))
+            .unwrap()
+    }
+
+    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
+        let ParsedGitRemote { owner, repo } = remote;
+        let BuildPermalinkParams {
+            sha,
+            path,
+            selection,
+        } = params;
+
+        let mut permalink = self
+            .base_url()
+            .join(&format!("~{owner}/{repo}/tree/{sha}/item/{path}"))
+            .unwrap();
+        permalink.set_fragment(
+            selection
+                .map(|selection| self.line_fragment(&selection))
+                .as_deref(),
+        );
+        permalink
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_build_sourcehut_permalink_from_ssh_url() {
+        let remote = ParsedGitRemote {
+            owner: "rajveermalviya",
+            repo: "zed",
+        };
+        let permalink = Sourcehut.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: None,
+            },
+        );
+
+        let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_sourcehut_permalink_from_ssh_url_with_git_prefix() {
+        let remote = ParsedGitRemote {
+            owner: "rajveermalviya",
+            repo: "zed.git",
+        };
+        let permalink = Sourcehut.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: None,
+            },
+        );
+
+        let expected_url = "https://git.sr.ht/~rajveermalviya/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_from_ssh_url_single_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "rajveermalviya",
+            repo: "zed",
+        };
+        let permalink = Sourcehut.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: Some(6..6),
+            },
+        );
+
+        let expected_url = "https://git.sr.ht/~rajveermalviya/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_permalink_from_ssh_url_multi_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "rajveermalviya",
+            repo: "zed",
+        };
+        let permalink = Sourcehut.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
+                path: "crates/editor/src/git/permalink.rs",
+                selection: Some(23..47),
+            },
+        );
+
+        let expected_url = "https://git.sr.ht/~rajveermalviya/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_permalink_from_https_url() {
+        let remote = ParsedGitRemote {
+            owner: "rajveermalviya",
+            repo: "zed",
+        };
+        let permalink = Sourcehut.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
+                path: "crates/zed/src/main.rs",
+                selection: None,
+            },
+        );
+
+        let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/zed/src/main.rs";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_sourcehut_permalink_from_https_url_single_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "rajveermalviya",
+            repo: "zed",
+        };
+        let permalink = Sourcehut.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
+                path: "crates/zed/src/main.rs",
+                selection: Some(6..6),
+            },
+        );
+
+        let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/zed/src/main.rs#L7";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+
+    #[test]
+    fn test_build_sourcehut_permalink_from_https_url_multi_line_selection() {
+        let remote = ParsedGitRemote {
+            owner: "rajveermalviya",
+            repo: "zed",
+        };
+        let permalink = Sourcehut.build_permalink(
+            remote,
+            BuildPermalinkParams {
+                sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
+                path: "crates/zed/src/main.rs",
+                selection: Some(23..47),
+            },
+        );
+
+        let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/zed/src/main.rs#L24-48";
+        assert_eq!(permalink.to_string(), expected_url.to_string())
+    }
+}

crates/git/src/permalink.rs 🔗

@@ -1,680 +0,0 @@
-use std::ops::Range;
-
-use anyhow::{anyhow, Result};
-use url::Url;
-
-use crate::hosting_provider::HostingProvider;
-
-pub struct BuildPermalinkParams<'a> {
-    pub remote_url: &'a str,
-    pub sha: &'a str,
-    pub path: &'a str,
-    pub selection: Option<Range<u32>>,
-}
-
-pub fn build_permalink(params: BuildPermalinkParams) -> Result<Url> {
-    let BuildPermalinkParams {
-        remote_url,
-        sha,
-        path,
-        selection,
-    } = params;
-
-    let ParsedGitRemote {
-        provider,
-        owner,
-        repo,
-    } = parse_git_remote_url(remote_url)
-        .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
-
-    let path = match provider {
-        HostingProvider::Github => format!("{owner}/{repo}/blob/{sha}/{path}"),
-        HostingProvider::Gitlab => format!("{owner}/{repo}/-/blob/{sha}/{path}"),
-        HostingProvider::Gitee => format!("{owner}/{repo}/blob/{sha}/{path}"),
-        HostingProvider::Bitbucket => format!("{owner}/{repo}/src/{sha}/{path}"),
-        HostingProvider::Sourcehut => format!("~{owner}/{repo}/tree/{sha}/item/{path}"),
-        HostingProvider::Codeberg => format!("{owner}/{repo}/src/commit/{sha}/{path}"),
-    };
-    let line_fragment = selection.map(|selection| provider.line_fragment(&selection));
-
-    let mut permalink = provider.base_url().join(&path).unwrap();
-    permalink.set_fragment(line_fragment.as_deref());
-    Ok(permalink)
-}
-
-#[derive(Debug)]
-pub struct ParsedGitRemote<'a> {
-    pub provider: HostingProvider,
-    pub owner: &'a str,
-    pub repo: &'a str,
-}
-
-pub struct BuildCommitPermalinkParams<'a> {
-    pub remote: &'a ParsedGitRemote<'a>,
-    pub sha: &'a str,
-}
-
-pub fn build_commit_permalink(params: BuildCommitPermalinkParams) -> Url {
-    let BuildCommitPermalinkParams { sha, remote } = params;
-
-    let ParsedGitRemote {
-        provider,
-        owner,
-        repo,
-    } = remote;
-
-    let path = match provider {
-        HostingProvider::Github => format!("{owner}/{repo}/commit/{sha}"),
-        HostingProvider::Gitlab => format!("{owner}/{repo}/-/commit/{sha}"),
-        HostingProvider::Gitee => format!("{owner}/{repo}/commit/{sha}"),
-        HostingProvider::Bitbucket => format!("{owner}/{repo}/commits/{sha}"),
-        HostingProvider::Sourcehut => format!("~{owner}/{repo}/commit/{sha}"),
-        HostingProvider::Codeberg => format!("{owner}/{repo}/commit/{sha}"),
-    };
-
-    provider.base_url().join(&path).unwrap()
-}
-
-pub fn parse_git_remote_url(url: &str) -> Option<ParsedGitRemote> {
-    if url.starts_with("git@github.com:") || url.starts_with("https://github.com/") {
-        let repo_with_owner = url
-            .trim_start_matches("git@github.com:")
-            .trim_start_matches("https://github.com/")
-            .trim_end_matches(".git");
-
-        let (owner, repo) = repo_with_owner.split_once('/')?;
-
-        return Some(ParsedGitRemote {
-            provider: HostingProvider::Github,
-            owner,
-            repo,
-        });
-    }
-
-    if url.starts_with("git@gitlab.com:") || url.starts_with("https://gitlab.com/") {
-        let repo_with_owner = url
-            .trim_start_matches("git@gitlab.com:")
-            .trim_start_matches("https://gitlab.com/")
-            .trim_end_matches(".git");
-
-        let (owner, repo) = repo_with_owner.split_once('/')?;
-
-        return Some(ParsedGitRemote {
-            provider: HostingProvider::Gitlab,
-            owner,
-            repo,
-        });
-    }
-
-    if url.starts_with("git@gitee.com:") || url.starts_with("https://gitee.com/") {
-        let repo_with_owner = url
-            .trim_start_matches("git@gitee.com:")
-            .trim_start_matches("https://gitee.com/")
-            .trim_end_matches(".git");
-
-        let (owner, repo) = repo_with_owner.split_once('/')?;
-
-        return Some(ParsedGitRemote {
-            provider: HostingProvider::Gitee,
-            owner,
-            repo,
-        });
-    }
-
-    if url.contains("bitbucket.org") {
-        let (_, repo_with_owner) = url.trim_end_matches(".git").split_once("bitbucket.org")?;
-        let (owner, repo) = repo_with_owner
-            .trim_start_matches('/')
-            .trim_start_matches(':')
-            .split_once('/')?;
-
-        return Some(ParsedGitRemote {
-            provider: HostingProvider::Bitbucket,
-            owner,
-            repo,
-        });
-    }
-
-    if url.starts_with("git@git.sr.ht:") || url.starts_with("https://git.sr.ht/") {
-        // sourcehut indicates a repo with '.git' suffix as a separate repo.
-        // For example, "git@git.sr.ht:~username/repo" and "git@git.sr.ht:~username/repo.git"
-        // are two distinct repositories.
-        let repo_with_owner = url
-            .trim_start_matches("git@git.sr.ht:~")
-            .trim_start_matches("https://git.sr.ht/~");
-
-        let (owner, repo) = repo_with_owner.split_once('/')?;
-
-        return Some(ParsedGitRemote {
-            provider: HostingProvider::Sourcehut,
-            owner,
-            repo,
-        });
-    }
-
-    if url.starts_with("git@codeberg.org:") || url.starts_with("https://codeberg.org/") {
-        let repo_with_owner = url
-            .trim_start_matches("git@codeberg.org:")
-            .trim_start_matches("https://codeberg.org/")
-            .trim_end_matches(".git");
-
-        let (owner, repo) = repo_with_owner.split_once('/')?;
-
-        return Some(ParsedGitRemote {
-            provider: HostingProvider::Codeberg,
-            owner,
-            repo,
-        });
-    }
-
-    None
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn test_build_github_permalink_from_ssh_url() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@github.com:zed-industries/zed.git",
-            sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: None,
-        })
-        .unwrap();
-
-        let expected_url = "https://github.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_github_permalink_from_ssh_url_single_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@github.com:zed-industries/zed.git",
-            sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: Some(6..6),
-        })
-        .unwrap();
-
-        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_github_permalink_from_ssh_url_multi_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@github.com:zed-industries/zed.git",
-            sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: Some(23..47),
-        })
-        .unwrap();
-
-        let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_github_permalink_from_https_url() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://github.com/zed-industries/zed.git",
-            sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
-            path: "crates/zed/src/main.rs",
-            selection: None,
-        })
-        .unwrap();
-
-        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_github_permalink_from_https_url_single_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://github.com/zed-industries/zed.git",
-            sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
-            path: "crates/zed/src/main.rs",
-            selection: Some(6..6),
-        })
-        .unwrap();
-
-        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_github_permalink_from_https_url_multi_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://github.com/zed-industries/zed.git",
-            sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
-            path: "crates/zed/src/main.rs",
-            selection: Some(23..47),
-        })
-        .unwrap();
-
-        let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-L48";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_gitlab_permalink_from_ssh_url() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@gitlab.com:zed-industries/zed.git",
-            sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: None,
-        })
-        .unwrap();
-
-        let expected_url = "https://gitlab.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_permalink_from_ssh_url_single_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@gitlab.com:zed-industries/zed.git",
-            sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: Some(6..6),
-        })
-        .unwrap();
-
-        let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_gitlab_permalink_from_ssh_url_multi_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@gitlab.com:zed-industries/zed.git",
-            sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: Some(23..47),
-        })
-        .unwrap();
-
-        let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-48";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_gitlab_permalink_from_https_url() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://gitlab.com/zed-industries/zed.git",
-            sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
-            path: "crates/zed/src/main.rs",
-            selection: None,
-        })
-        .unwrap();
-
-        let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_gitlab_permalink_from_https_url_single_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://gitlab.com/zed-industries/zed.git",
-            sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
-            path: "crates/zed/src/main.rs",
-            selection: Some(6..6),
-        })
-        .unwrap();
-
-        let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_gitlab_permalink_from_https_url_multi_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://gitlab.com/zed-industries/zed.git",
-            sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
-            path: "crates/zed/src/main.rs",
-            selection: Some(23..47),
-        })
-        .unwrap();
-
-        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_gitee_permalink_from_ssh_url() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@gitee.com:libkitten/zed.git",
-            sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: None,
-        })
-        .unwrap();
-
-        let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_gitee_permalink_from_ssh_url_single_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@gitee.com:libkitten/zed.git",
-            sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: Some(6..6),
-        })
-        .unwrap();
-
-        let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L7";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_gitee_permalink_from_ssh_url_multi_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@gitee.com:libkitten/zed.git",
-            sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: Some(23..47),
-        })
-        .unwrap();
-
-        let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L24-48";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_gitee_permalink_from_https_url() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://gitee.com/libkitten/zed.git",
-            sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
-            path: "crates/zed/src/main.rs",
-            selection: None,
-        })
-        .unwrap();
-
-        let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_gitee_permalink_from_https_url_single_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://gitee.com/libkitten/zed.git",
-            sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
-            path: "crates/zed/src/main.rs",
-            selection: Some(6..6),
-        })
-        .unwrap();
-
-        let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs#L7";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_gitee_permalink_from_https_url_multi_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://gitee.com/libkitten/zed.git",
-            sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
-            path: "crates/zed/src/main.rs",
-            selection: Some(23..47),
-        })
-        .unwrap();
-        let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs#L24-48";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_parse_git_remote_url_bitbucket_https_with_username() {
-        let url = "https://thorstenballzed@bitbucket.org/thorstenzed/testingrepo.git";
-        let parsed = parse_git_remote_url(url).unwrap();
-        assert!(matches!(parsed.provider, HostingProvider::Bitbucket));
-        assert_eq!(parsed.owner, "thorstenzed");
-        assert_eq!(parsed.repo, "testingrepo");
-    }
-
-    #[test]
-    fn test_parse_git_remote_url_bitbucket_https_without_username() {
-        let url = "https://bitbucket.org/thorstenzed/testingrepo.git";
-        let parsed = parse_git_remote_url(url).unwrap();
-        assert!(matches!(parsed.provider, HostingProvider::Bitbucket));
-        assert_eq!(parsed.owner, "thorstenzed");
-        assert_eq!(parsed.repo, "testingrepo");
-    }
-
-    #[test]
-    fn test_parse_git_remote_url_bitbucket_git() {
-        let url = "git@bitbucket.org:thorstenzed/testingrepo.git";
-        let parsed = parse_git_remote_url(url).unwrap();
-        assert!(matches!(parsed.provider, HostingProvider::Bitbucket));
-        assert_eq!(parsed.owner, "thorstenzed");
-        assert_eq!(parsed.repo, "testingrepo");
-    }
-
-    #[test]
-    fn test_build_bitbucket_permalink_from_ssh_url() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@bitbucket.org:thorstenzed/testingrepo.git",
-            sha: "f00b4r",
-            path: "main.rs",
-            selection: None,
-        })
-        .unwrap();
-
-        let expected_url = "https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_bitbucket_permalink_from_ssh_url_single_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@bitbucket.org:thorstenzed/testingrepo.git",
-            sha: "f00b4r",
-            path: "main.rs",
-            selection: Some(6..6),
-        })
-        .unwrap();
-
-        let expected_url =
-            "https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs#lines-7";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_bitbucket_permalink_from_ssh_url_multi_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@bitbucket.org:thorstenzed/testingrepo.git",
-            sha: "f00b4r",
-            path: "main.rs",
-            selection: Some(23..47),
-        })
-        .unwrap();
-
-        let expected_url =
-            "https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs#lines-24:48";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_sourcehut_permalink_from_ssh_url() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@git.sr.ht:~rajveermalviya/zed",
-            sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: None,
-        })
-        .unwrap();
-
-        let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_sourcehut_permalink_from_ssh_url_with_git_prefix() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@git.sr.ht:~rajveermalviya/zed.git",
-            sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: None,
-        })
-        .unwrap();
-
-        let expected_url = "https://git.sr.ht/~rajveermalviya/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_from_ssh_url_single_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@git.sr.ht:~rajveermalviya/zed",
-            sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: Some(6..6),
-        })
-        .unwrap();
-
-        let expected_url = "https://git.sr.ht/~rajveermalviya/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_permalink_from_ssh_url_multi_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@git.sr.ht:~rajveermalviya/zed",
-            sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: Some(23..47),
-        })
-        .unwrap();
-
-        let expected_url = "https://git.sr.ht/~rajveermalviya/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_permalink_from_https_url() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://git.sr.ht/~rajveermalviya/zed",
-            sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
-            path: "crates/zed/src/main.rs",
-            selection: None,
-        })
-        .unwrap();
-
-        let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/zed/src/main.rs";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_sourcehut_permalink_from_https_url_single_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://git.sr.ht/~rajveermalviya/zed",
-            sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
-            path: "crates/zed/src/main.rs",
-            selection: Some(6..6),
-        })
-        .unwrap();
-
-        let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/zed/src/main.rs#L7";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_sourcehut_permalink_from_https_url_multi_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://git.sr.ht/~rajveermalviya/zed",
-            sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
-            path: "crates/zed/src/main.rs",
-            selection: Some(23..47),
-        })
-        .unwrap();
-
-        let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/zed/src/main.rs#L24-48";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_codeberg_permalink_from_ssh_url() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@codeberg.org:rajveermalviya/zed.git",
-            sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: None,
-        })
-        .unwrap();
-
-        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_codeberg_permalink_from_ssh_url_single_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@codeberg.org:rajveermalviya/zed.git",
-            sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: Some(6..6),
-        })
-        .unwrap();
-
-        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_codeberg_permalink_from_ssh_url_multi_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "git@codeberg.org:rajveermalviya/zed.git",
-            sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
-            path: "crates/editor/src/git/permalink.rs",
-            selection: Some(23..47),
-        })
-        .unwrap();
-
-        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_codeberg_permalink_from_https_url() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://codeberg.org/rajveermalviya/zed.git",
-            sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
-            path: "crates/zed/src/main.rs",
-            selection: None,
-        })
-        .unwrap();
-
-        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_codeberg_permalink_from_https_url_single_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://codeberg.org/rajveermalviya/zed.git",
-            sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
-            path: "crates/zed/src/main.rs",
-            selection: Some(6..6),
-        })
-        .unwrap();
-
-        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs#L7";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-
-    #[test]
-    fn test_build_codeberg_permalink_from_https_url_multi_line_selection() {
-        let permalink = build_permalink(BuildPermalinkParams {
-            remote_url: "https://codeberg.org/rajveermalviya/zed.git",
-            sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
-            path: "crates/zed/src/main.rs",
-            selection: Some(23..47),
-        })
-        .unwrap();
-
-        let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs#L24-L48";
-        assert_eq!(permalink.to_string(), expected_url.to_string())
-    }
-}

crates/git/src/pull_request.rs 🔗

@@ -1,83 +0,0 @@
-use lazy_static::lazy_static;
-use url::Url;
-
-use crate::{hosting_provider::HostingProvider, permalink::ParsedGitRemote};
-
-lazy_static! {
-    static ref GITHUB_PULL_REQUEST_NUMBER: regex::Regex =
-        regex::Regex::new(r"\(#(\d+)\)$").unwrap();
-}
-
-#[derive(Clone, Debug)]
-pub struct PullRequest {
-    pub number: u32,
-    pub url: Url,
-}
-
-pub fn extract_pull_request(remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
-    match remote.provider {
-        HostingProvider::Github => {
-            let line = message.lines().next()?;
-            let capture = GITHUB_PULL_REQUEST_NUMBER.captures(line)?;
-            let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
-
-            let mut url = remote.provider.base_url();
-            let path = format!("/{}/{}/pull/{}", remote.owner, remote.repo, number);
-            url.set_path(&path);
-
-            Some(PullRequest { number, url })
-        }
-        _ => None,
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use unindent::Unindent;
-
-    use crate::{
-        hosting_provider::HostingProvider, permalink::ParsedGitRemote,
-        pull_request::extract_pull_request,
-    };
-
-    #[test]
-    fn test_github_pull_requests() {
-        let remote = ParsedGitRemote {
-            provider: HostingProvider::Github,
-            owner: "zed-industries",
-            repo: "zed",
-        };
-
-        let message = "This does not contain a pull request";
-        assert!(extract_pull_request(&remote, message).is_none());
-
-        // Pull request number at end of first line
-        let message = r#"
-            project panel: do not expand collapsed worktrees on "collapse all entries" (#10687)
-
-            Fixes #10597
-
-            Release Notes:
-
-            - Fixed "project panel: collapse all entries" expanding collapsed worktrees.
-            "#
-        .unindent();
-
-        assert_eq!(
-            extract_pull_request(&remote, &message)
-                .unwrap()
-                .url
-                .as_str(),
-            "https://github.com/zed-industries/zed/pull/10687"
-        );
-
-        // Pull request number in middle of line, which we want to ignore
-        let message = r#"
-            Follow-up to #10687 to fix problems
-
-            See the original PR, this is a fix.
-            "#
-        .unindent();
-        assert!(extract_pull_request(&remote, &message).is_none());
-    }
-}