ytil_git/
remote.rs

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