Skip to main content

anodizer_core/git/
remote.rs

1use anyhow::Result;
2
3use super::git_output;
4
5/// Strip userinfo (credentials) from an HTTPS URL.
6///
7/// If the URL starts with `https://` and contains `@`, everything between
8/// `://` and `@` is removed (e.g. `https://user:token@github.com/...` becomes
9/// `https://github.com/...`). Non-HTTPS URLs are returned unchanged.
10pub(super) fn strip_url_credentials(url: &str) -> String {
11    if let Some(rest) = url.strip_prefix("https://")
12        && let Some(at_pos) = rest.find('@')
13    {
14        return format!("https://{}", &rest[at_pos + 1..]);
15    }
16    url.to_string()
17}
18
19/// Parse owner and repo name from a GitHub remote URL.
20/// Supports HTTPS (`https://github.com/owner/repo.git`) and SSH (`git@github.com:owner/repo.git`).
21pub fn parse_github_remote(url: &str) -> Option<(String, String)> {
22    let url = url.trim();
23    if url.is_empty() {
24        return None;
25    }
26
27    // Strip trailing ".git" if present
28    let url = url.strip_suffix(".git").unwrap_or(url);
29
30    // HTTPS: https://github.com/owner/repo
31    if let Some(path) = url.strip_prefix("https://github.com/") {
32        let parts: Vec<&str> = path.splitn(3, '/').collect();
33        if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
34            return Some((parts[0].to_string(), parts[1].to_string()));
35        }
36    }
37
38    // SSH: git@github.com:owner/repo
39    if let Some(path) = url.strip_prefix("git@github.com:") {
40        let parts: Vec<&str> = path.splitn(3, '/').collect();
41        if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
42            return Some((parts[0].to_string(), parts[1].to_string()));
43        }
44    }
45
46    None
47}
48
49/// Get the GitHub owner/name from the `origin` remote.
50pub fn detect_github_repo() -> Result<(String, String)> {
51    let url = git_output(&["remote", "get-url", "origin"])?;
52    parse_github_remote(&url).ok_or_else(|| {
53        // Strip inline `https://<token>@...` userinfo before surfacing
54        // the URL in a user-visible error.
55        let safe = strip_url_credentials(&url);
56        anyhow::anyhow!(
57            "could not parse GitHub owner/repo from remote URL: {}",
58            safe
59        )
60    })
61}
62
63/// Parse owner and repo from any git remote URL, regardless of host.
64///
65/// Supports HTTPS (`https://host/owner/repo.git`) and SSH (`git@host:owner/repo.git`)
66/// formats. Returns `(owner, repo)` with `.git` suffix stripped.
67///
68/// This is a host-agnostic version of [`parse_github_remote`], suitable for
69/// GitLab, Gitea, and other SCM providers.
70pub fn parse_remote_owner_repo(url: &str) -> Option<(String, String)> {
71    let url = url.trim();
72    if url.is_empty() {
73        return None;
74    }
75
76    // Strip trailing ".git" if present
77    let url = url.strip_suffix(".git").unwrap_or(url);
78
79    // HTTPS: https://host/owner/repo or https://host/group/subgroup/repo
80    if url.starts_with("https://") || url.starts_with("http://") {
81        // Strip scheme and host
82        let after_scheme = if let Some(rest) = url.strip_prefix("https://") {
83            rest
84        } else {
85            url.strip_prefix("http://")?
86        };
87        // Strip any credentials (user:pass@host or user@host)
88        let after_host = after_scheme.find('/').map(|i| &after_scheme[i + 1..])?;
89        // For nested groups (e.g. group/subgroup/repo), the owner is everything
90        // up to the last slash.
91        let last_slash = after_host.rfind('/')?;
92        let owner = &after_host[..last_slash];
93        let repo = &after_host[last_slash + 1..];
94        if !owner.is_empty() && !repo.is_empty() {
95            return Some((owner.to_string(), repo.to_string()));
96        }
97    }
98
99    // SSH: git@host:owner/repo or git@host:group/subgroup/repo
100    if let Some(colon_pos) = url.find(':') {
101        let before_colon = &url[..colon_pos];
102        // Ensure it looks like an SSH URL (contains @, no //)
103        if before_colon.contains('@') && !before_colon.contains("//") {
104            let path = &url[colon_pos + 1..];
105            let last_slash = path.rfind('/')?;
106            let owner = &path[..last_slash];
107            let repo = &path[last_slash + 1..];
108            if !owner.is_empty() && !repo.is_empty() {
109                return Some((owner.to_string(), repo.to_string()));
110            }
111        }
112    }
113
114    None
115}
116
117/// Get the owner/repo from the `origin` remote, regardless of SCM host.
118///
119/// Uses [`parse_remote_owner_repo`] which works with any git hosting provider
120/// (GitHub, GitLab, Gitea, etc.).
121pub fn detect_owner_repo() -> Result<(String, String)> {
122    let url = git_output(&["remote", "get-url", "origin"])?;
123    parse_remote_owner_repo(&url).ok_or_else(|| {
124        // Strip inline userinfo before surfacing the URL.
125        let safe = strip_url_credentials(&url);
126        anyhow::anyhow!("could not parse owner/repo from remote URL: {}", safe)
127    })
128}