Skip to main content

ytil_git/
remote.rs

1use git2::Reference;
2use git2::Repository;
3use rootcause::bail;
4use rootcause::prelude::ResultExt;
5use rootcause::report;
6
7/// Retrieves the default remote HEAD reference from the repository.
8///
9/// Iterates over all configured remotes and returns the first valid
10/// `refs/remotes/{remote}/HEAD` reference, which typically points to the
11/// default branch (e.g., main, or master) on that remote.
12///
13/// # Errors
14/// - If no remote has a valid `HEAD` reference.
15pub fn get_default(repo: &Repository) -> rootcause::Result<Reference<'_>> {
16    for remote_name in repo.remotes()?.iter().flatten() {
17        if let Ok(default_remote_ref) = repo.find_reference(&format!("refs/remotes/{remote_name}/HEAD")) {
18            return Ok(default_remote_ref);
19        }
20    }
21    bail!("error missing default remote")
22}
23
24/// Retrieves HTTPS URLs for all configured remotes in the repository.
25///
26/// # Errors
27/// - If listing remotes fails.
28/// - If finding a remote by name fails.
29/// - If a remote has no URL configured.
30/// - If URL has an unsupported protocol.
31pub fn get_https_urls(repo: &Repository) -> rootcause::Result<Vec<String>> {
32    let mut https_urls = vec![];
33    for remote_name in repo.remotes()?.iter().flatten() {
34        let remote = repo.find_remote(remote_name)?;
35        let url = remote
36            .url()
37            .ok_or_else(|| report!("error invalid URL for remote"))
38            .attach_with(|| format!("remote={remote_name:?}"))
39            .and_then(map_to_https_url)?;
40        https_urls.push(url);
41    }
42    Ok(https_urls)
43}
44
45/// Supported Git hosting providers.
46pub enum GitProvider {
47    /// GitHub.com or GitHub Enterprise.
48    GitHub,
49    /// GitLab.com or self-hosted GitLab.
50    GitLab,
51}
52
53impl GitProvider {
54    /// Detects the Git provider by inspecting HTTP response headers from the given URL.
55    ///
56    /// # Errors
57    /// - If the HTTP request fails (network issues, invalid URL, etc.).
58    ///
59    /// # Rationale
60    /// Header-based detection avoids parsing HTML content or relying on URL patterns,
61    /// providing a more reliable and lightweight approach to provider identification.
62    pub fn get(url: &str) -> rootcause::Result<Option<Self>> {
63        let resp = ureq::get(url).call()?;
64
65        for (name, _) in resp.headers() {
66            let name = name.as_str();
67            if name.contains("gitlab") {
68                return Ok(Some(Self::GitLab));
69            }
70            if name.contains("github") {
71                return Ok(Some(Self::GitHub));
72            }
73        }
74
75        Ok(None)
76    }
77}
78
79fn map_to_https_url(url: &str) -> rootcause::Result<String> {
80    if url.starts_with("https://") {
81        return Ok(url.to_owned());
82    }
83    if let Some(rest) = url
84        .strip_prefix("ssh://")
85        .and_then(|no_ssh| no_ssh.strip_prefix("git@"))
86        .or_else(|| url.strip_prefix("git@"))
87    {
88        return Ok(format!("https://{}", rest.replace(':', "/").trim_end_matches(".git")));
89    }
90    Err(report!("error unsupported protocol for URL").attach(format!("url={url}")))
91}
92
93#[cfg(test)]
94mod tests {
95    use rstest::rstest;
96
97    use super::*;
98
99    #[rstest]
100    #[case("https://github.com/user/repo", "https://github.com/user/repo")]
101    #[case("https://gitlab.com/user/repo", "https://gitlab.com/user/repo")]
102    #[case("git@github.com:user/repo.git", "https://github.com/user/repo")]
103    #[case("ssh://git@github.com/user/repo.git", "https://github.com/user/repo")]
104    #[case("git@gitlab.com:user/repo.git", "https://gitlab.com/user/repo")]
105    #[case("https://bitbucket.org/user/repo", "https://bitbucket.org/user/repo")]
106    fn map_to_https_url_when_valid_input_maps_successfully(#[case] input: &str, #[case] expected: &str) {
107        let result = map_to_https_url(input);
108        assert2::assert!(let Ok(actual) = result);
109        pretty_assertions::assert_eq!(actual, expected);
110    }
111
112    #[rstest]
113    #[case("ftp://example.com/repo")]
114    #[case("http://github.com/user/repo")]
115    #[case("invalid")]
116    #[case("")]
117    fn map_to_https_url_when_unsupported_protocol_returns_error(#[case] input: &str) {
118        let result = map_to_https_url(input);
119        assert2::assert!(let Err(err) = result);
120        assert!(err.to_string().contains("error unsupported protocol for URL"));
121    }
122}