Files
oak-gpui/crates/git_hosting_providers/src/providers/github.rs
T
Zachary HammandPeter Tripp 66acc2698a git_hosting_providers: Support GitHub remote URLs that start with a slash (#34134)
Remote URLs like `git@github.com:/zed-industries/zed` are valid git
remotes, but currently Zed does not parse them correctly. The result is
invalid "permalink" generation, and presumably other bugs anywhere that
git remote urls are required for the current repository. This ensures
Zed will parse those URLs correctly.

Release Notes:

- Improved support for GitHub remote urls where the
username/organization is preceded by an extra `/` to match the behavior
of `git`, `gh` and `github.com`.

Co-authored-by: Peter Tripp <peter@zed.dev>
2025-07-24 18:25:21 -04:00

494 lines
15 KiB
Rust

use std::str::FromStr;
use std::sync::{Arc, LazyLock};
use anyhow::{Context as _, Result, bail};
use async_trait::async_trait;
use futures::AsyncReadExt;
use gpui::SharedString;
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
use regex::Regex;
use serde::Deserialize;
use url::Url;
use git::{
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
PullRequest, RemoteUrl,
};
use crate::get_host_from_git_remote_url;
fn pull_request_number_regex() -> &'static Regex {
static PULL_REQUEST_NUMBER_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\(#(\d+)\)$").unwrap());
&PULL_REQUEST_NUMBER_REGEX
}
#[derive(Debug, Deserialize)]
struct CommitDetails {
commit: Commit,
author: Option<User>,
}
#[derive(Debug, Deserialize)]
struct Commit {
author: Author,
}
#[derive(Debug, Deserialize)]
struct Author {
email: String,
}
#[derive(Debug, Deserialize)]
struct User {
pub id: u64,
pub avatar_url: String,
}
#[derive(Debug)]
pub struct Github {
name: String,
base_url: Url,
}
impl Github {
pub fn new(name: impl Into<String>, base_url: Url) -> Self {
Self {
name: name.into(),
base_url,
}
}
pub fn public_instance() -> Self {
Self::new("GitHub", Url::parse("https://github.com").unwrap())
}
pub fn from_remote_url(remote_url: &str) -> Result<Self> {
let host = get_host_from_git_remote_url(remote_url)?;
if host == "github.com" {
bail!("the GitHub instance is not self-hosted");
}
// TODO: detecting self hosted instances by checking whether "github" is in the url or not
// is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
// information.
if !host.contains("github") {
bail!("not a GitHub URL");
}
Ok(Self::new(
"GitHub Self-Hosted",
Url::parse(&format!("https://{}", host))?,
))
}
async fn fetch_github_commit_author(
&self,
repo_owner: &str,
repo: &str,
commit: &str,
client: &Arc<dyn HttpClient>,
) -> Result<Option<User>> {
let Some(host) = self.base_url.host_str() else {
bail!("failed to get host from github base url");
};
let url = format!("https://api.{host}/repos/{repo_owner}/{repo}/commits/{commit}");
let mut request = Request::get(&url)
.header("Content-Type", "application/json")
.follow_redirects(http_client::RedirectPolicy::FollowAll);
if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
request = request.header("Authorization", format!("Bearer {}", github_token));
}
let mut response = client
.send(request.body(AsyncBody::default())?)
.await
.with_context(|| format!("error fetching GitHub commit details at {:?}", url))?;
let mut body = Vec::new();
response.body_mut().read_to_end(&mut body).await?;
if response.status().is_client_error() {
let text = String::from_utf8_lossy(body.as_slice());
bail!(
"status error {}, response: {text:?}",
response.status().as_u16()
);
}
let body_str = std::str::from_utf8(&body)?;
serde_json::from_str::<CommitDetails>(body_str)
.map(|commit| commit.author)
.context("failed to deserialize GitHub commit details")
}
}
#[async_trait]
impl GitHostingProvider for Github {
fn name(&self) -> String {
self.name.clone()
}
fn base_url(&self) -> Url {
self.base_url.clone()
}
fn supports_avatars(&self) -> bool {
// Avatars are not supported for self-hosted GitHub instances
// See tracking issue: https://github.com/zed-industries/zed/issues/11043
&self.name == "GitHub"
}
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(&self, url: &str) -> Option<ParsedGitRemote> {
let url = RemoteUrl::from_str(url).ok()?;
let host = url.host_str()?;
if host != self.base_url.host_str()? {
return None;
}
let mut path_segments = url.path_segments()?;
let mut owner = path_segments.next()?;
if owner.is_empty() {
owner = path_segments.next()?;
}
let repo = path_segments.next()?.trim_end_matches(".git");
Some(ParsedGitRemote {
owner: owner.into(),
repo: repo.into(),
})
}
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();
if path.ends_with(".md") {
permalink.set_query(Some("plain=1"));
}
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: SharedString,
http_client: Arc<dyn HttpClient>,
) -> Result<Option<Url>> {
let commit = commit.to_string();
let avatar_url = self
.fetch_github_commit_author(repo_owner, repo, &commit, &http_client)
.await?
.map(|author| -> Result<Url, url::ParseError> {
let mut url = Url::parse(&author.avatar_url)?;
url.set_query(Some("size=128"));
Ok(url)
})
.transpose()?;
Ok(avatar_url)
}
}
#[cfg(test)]
mod tests {
use indoc::indoc;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_remote_url_with_root_slash() {
let remote_url = "git@github.com:/zed-industries/zed";
let parsed_remote = Github::public_instance()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_invalid_self_hosted_remote_url() {
let remote_url = "git@github.com:zed-industries/zed.git";
let github = Github::from_remote_url(remote_url);
assert!(github.is_err());
}
#[test]
fn test_from_remote_url_ssh() {
let remote_url = "git@github.my-enterprise.com:zed-industries/zed.git";
let github = Github::from_remote_url(remote_url).unwrap();
assert!(!github.supports_avatars());
assert_eq!(github.name, "GitHub Self-Hosted".to_string());
assert_eq!(
github.base_url,
Url::parse("https://github.my-enterprise.com").unwrap()
);
}
#[test]
fn test_from_remote_url_https() {
let remote_url = "https://github.my-enterprise.com/zed-industries/zed.git";
let github = Github::from_remote_url(remote_url).unwrap();
assert!(!github.supports_avatars());
assert_eq!(github.name, "GitHub Self-Hosted".to_string());
assert_eq!(
github.base_url,
Url::parse("https://github.my-enterprise.com").unwrap()
);
}
#[test]
fn test_parse_remote_url_given_self_hosted_ssh_url() {
let remote_url = "git@github.my-enterprise.com:zed-industries/zed.git";
let parsed_remote = Github::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_self_hosted_https_url_with_subgroup() {
let remote_url = "https://github.my-enterprise.com/zed-industries/zed.git";
let parsed_remote = Github::from_remote_url(remote_url)
.unwrap()
.parse_remote_url(remote_url)
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_ssh_url() {
let parsed_remote = Github::public_instance()
.parse_remote_url("git@github.com:zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_https_url() {
let parsed_remote = Github::public_instance()
.parse_remote_url("https://github.com/zed-industries/zed.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
}
);
}
#[test]
fn test_parse_remote_url_given_https_url_with_username() {
let parsed_remote = Github::public_instance()
.parse_remote_url("https://jlannister@github.com/some-org/some-repo.git")
.unwrap();
assert_eq!(
parsed_remote,
ParsedGitRemote {
owner: "some-org".into(),
repo: "some-repo".into(),
}
);
}
#[test]
fn test_build_github_permalink_from_ssh_url() {
let remote = ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
};
let permalink = Github::public_instance().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() {
let permalink = Github::public_instance().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
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_with_single_line_selection() {
let permalink = Github::public_instance().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
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_with_multi_line_selection() {
let permalink = Github::public_instance().build_permalink(
ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
},
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_github_pull_requests() {
let remote = ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
};
let github = Github::public_instance();
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 = indoc! {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.
"#
};
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 = indoc! {r#"
Follow-up to #10687 to fix problems
See the original PR, this is a fix.
"#
};
assert_eq!(github.extract_pull_request(&remote, &message), None);
}
}