Skip to main content

anodizer_core/git/
remote.rs

1use anyhow::Result;
2use std::path::Path;
3use std::process::Command;
4
5use super::git_output_in;
6use crate::redact::redact_url_credentials;
7
8/// Whether `remote` (e.g. `"origin"`) is configured in the git repo at `cwd`.
9///
10/// Probes `git remote get-url <remote>` and reports success. `GIT_TERMINAL_PROMPT=0`
11/// prevents the call from blocking on a credential prompt; `LC_ALL=C` pins
12/// machine-readable output. Any spawn or non-zero exit (no such remote) maps to
13/// `false` so callers can branch on presence without surfacing an error.
14pub fn has_remote_in(cwd: &Path, remote: &str) -> bool {
15    Command::new("git")
16        .current_dir(cwd)
17        .args(["remote", "get-url", remote])
18        .env("GIT_TERMINAL_PROMPT", "0")
19        .env("LC_ALL", "C")
20        .output()
21        .map(|o| o.status.success())
22        .unwrap_or(false)
23}
24
25/// Parse owner and repo name from a GitHub remote URL.
26/// Supports HTTPS (`https://github.com/owner/repo.git`) and SSH (`git@github.com:owner/repo.git`).
27pub fn parse_github_remote(url: &str) -> Option<(String, String)> {
28    let url = url.trim();
29    if url.is_empty() {
30        return None;
31    }
32
33    // Strip trailing ".git" if present
34    let url = url.strip_suffix(".git").unwrap_or(url);
35
36    // HTTPS: https://github.com/owner/repo
37    if let Some(path) = url.strip_prefix("https://github.com/") {
38        let parts: Vec<&str> = path.splitn(3, '/').collect();
39        if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
40            return Some((parts[0].to_string(), parts[1].to_string()));
41        }
42    }
43
44    // SSH: git@github.com:owner/repo
45    if let Some(path) = url.strip_prefix("git@github.com:") {
46        let parts: Vec<&str> = path.splitn(3, '/').collect();
47        if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
48            return Some((parts[0].to_string(), parts[1].to_string()));
49        }
50    }
51
52    None
53}
54
55/// Get the GitHub owner/name from the `origin` remote.
56pub fn detect_github_repo() -> Result<(String, String)> {
57    detect_github_repo_in(&std::env::current_dir()?)
58}
59
60/// Get the GitHub owner/name from the `origin` remote configured in `cwd`.
61///
62/// Path-taking sibling of [`detect_github_repo`]; runs
63/// `git remote get-url origin` with an explicit `current_dir` so callers
64/// (including tests against a temporary fixture repo) don't have to
65/// mutate the process-wide cwd.
66pub fn detect_github_repo_in(cwd: &Path) -> Result<(String, String)> {
67    let url = git_output_in(cwd, &["remote", "get-url", "origin"])?;
68    parse_github_remote(&url).ok_or_else(|| {
69        // Strip inline `<scheme>://<userinfo>@...` userinfo before surfacing
70        // the URL in a user-visible error.
71        let safe = redact_url_credentials(&url);
72        anyhow::anyhow!(
73            "could not parse GitHub owner/repo from remote URL: {}",
74            safe
75        )
76    })
77}
78
79/// Parse owner and repo from any git remote URL, regardless of host.
80///
81/// Supports HTTPS (`https://host/owner/repo.git`) and SSH (`git@host:owner/repo.git`)
82/// formats. Returns `(owner, repo)` with `.git` suffix stripped.
83///
84/// This is a host-agnostic version of [`parse_github_remote`], suitable for
85/// GitLab, Gitea, and other SCM providers.
86pub fn parse_remote_owner_repo(url: &str) -> Option<(String, String)> {
87    let url = url.trim();
88    if url.is_empty() {
89        return None;
90    }
91
92    // Strip trailing ".git" if present
93    let url = url.strip_suffix(".git").unwrap_or(url);
94
95    // HTTPS: https://host/owner/repo or https://host/group/subgroup/repo
96    if url.starts_with("https://") || url.starts_with("http://") {
97        // Strip scheme and host
98        let after_scheme = if let Some(rest) = url.strip_prefix("https://") {
99            rest
100        } else {
101            url.strip_prefix("http://")?
102        };
103        // Strip any credentials (user:pass@host or user@host)
104        let after_host = after_scheme.find('/').map(|i| &after_scheme[i + 1..])?;
105        // For nested groups (e.g. group/subgroup/repo), the owner is everything
106        // up to the last slash.
107        let last_slash = after_host.rfind('/')?;
108        let owner = &after_host[..last_slash];
109        let repo = &after_host[last_slash + 1..];
110        if !owner.is_empty() && !repo.is_empty() {
111            return Some((owner.to_string(), repo.to_string()));
112        }
113    }
114
115    // SSH: git@host:owner/repo or git@host:group/subgroup/repo
116    if let Some(colon_pos) = url.find(':') {
117        let before_colon = &url[..colon_pos];
118        // Ensure it looks like an SSH URL (contains @, no //)
119        if before_colon.contains('@') && !before_colon.contains("//") {
120            let path = &url[colon_pos + 1..];
121            let last_slash = path.rfind('/')?;
122            let owner = &path[..last_slash];
123            let repo = &path[last_slash + 1..];
124            if !owner.is_empty() && !repo.is_empty() {
125                return Some((owner.to_string(), repo.to_string()));
126            }
127        }
128    }
129
130    None
131}
132
133/// Convert a git remote URL into its web base (`https://host/owner/repo`),
134/// regardless of SCM host.
135///
136/// Accepts HTTPS (`https://host/owner/repo.git`) and SSH
137/// (`git@host:owner/repo.git`) forms, normalizes both to
138/// `https://host/owner/repo` (no `.git` suffix), and preserves nested
139/// groups (`group/subgroup/repo`). Returns `None` when the URL has no
140/// recognizable host or path.
141///
142/// This is the host-preserving counterpart of [`parse_remote_owner_repo`]:
143/// it keeps the host so callers (e.g. changelog compare-link footers) can
144/// build links against a self-hosted GitLab/Gitea instead of assuming
145/// `github.com`.
146pub fn parse_remote_web_base(url: &str) -> Option<String> {
147    let url = url.trim();
148    if url.is_empty() {
149        return None;
150    }
151    let url = url.strip_suffix(".git").unwrap_or(url);
152
153    // HTTPS/HTTP: normalize the scheme to https and drop any userinfo.
154    if let Some(rest) = url
155        .strip_prefix("https://")
156        .or_else(|| url.strip_prefix("http://"))
157    {
158        // Split host[:port]/path; drop credentials in the host segment.
159        let slash = rest.find('/')?;
160        let host_seg = &rest[..slash];
161        let path = &rest[slash + 1..];
162        let host = host_seg.rsplit('@').next().unwrap_or(host_seg);
163        if host.is_empty() || path.is_empty() {
164            return None;
165        }
166        return Some(format!("https://{}/{}", host, path));
167    }
168
169    // SSH: git@host:owner/repo
170    if let Some(colon_pos) = url.find(':') {
171        let before_colon = &url[..colon_pos];
172        if before_colon.contains('@') && !before_colon.contains("//") {
173            let host = before_colon.rsplit('@').next().unwrap_or(before_colon);
174            let path = &url[colon_pos + 1..];
175            if !host.is_empty() && !path.is_empty() {
176                return Some(format!("https://{}/{}", host, path));
177            }
178        }
179    }
180
181    None
182}
183
184/// Get the web base (`https://host/owner/repo`) for the `origin` remote
185/// configured in `cwd`, regardless of SCM host.
186///
187/// Path-taking helper used to build host-correct compare links (changelog
188/// footers) for self-hosted GitLab/Gitea as well as github.com.
189pub fn detect_remote_web_base_in(cwd: &Path) -> Result<String> {
190    let url = git_output_in(cwd, &["remote", "get-url", "origin"])?;
191    parse_remote_web_base(&url).ok_or_else(|| {
192        let safe = redact_url_credentials(&url);
193        anyhow::anyhow!("could not parse web base from remote URL: {}", safe)
194    })
195}
196
197/// Get the owner/repo from the `origin` remote, regardless of SCM host.
198///
199/// Uses [`parse_remote_owner_repo`] which works with any git hosting provider
200/// (GitHub, GitLab, Gitea, etc.).
201pub fn detect_owner_repo() -> Result<(String, String)> {
202    detect_owner_repo_in(&std::env::current_dir()?)
203}
204
205/// Get the owner/repo from the `origin` remote configured in `cwd`,
206/// regardless of SCM host.
207///
208/// Path-taking sibling of [`detect_owner_repo`] for use against an
209/// explicit working directory instead of the process-wide cwd.
210pub fn detect_owner_repo_in(cwd: &Path) -> Result<(String, String)> {
211    let url = git_output_in(cwd, &["remote", "get-url", "origin"])?;
212    parse_remote_owner_repo(&url).ok_or_else(|| {
213        // Strip inline userinfo before surfacing the URL.
214        let safe = redact_url_credentials(&url);
215        anyhow::anyhow!("could not parse owner/repo from remote URL: {}", safe)
216    })
217}