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(crate) 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 configured in `cwd`.
56///
57/// Runs `git remote get-url origin` with an explicit `current_dir` so callers
58/// (including tests against a temporary fixture repo) don't have to
59/// mutate the process-wide cwd.
60pub(crate) fn detect_github_repo_in(cwd: &Path) -> Result<(String, String)> {
61    let url = git_output_in(cwd, &["remote", "get-url", "origin"])?;
62    parse_github_remote(&url).ok_or_else(|| {
63        // Strip inline `<scheme>://<userinfo>@...` userinfo before surfacing
64        // the URL in a user-visible error.
65        let safe = redact_url_credentials(&url);
66        anyhow::anyhow!(
67            "could not parse GitHub owner/repo from remote URL: {}",
68            safe
69        )
70    })
71}
72
73/// Parse owner and repo from any git remote URL, regardless of host.
74///
75/// Supports HTTPS (`https://host/owner/repo.git`) and SSH (`git@host:owner/repo.git`)
76/// formats. Returns `(owner, repo)` with `.git` suffix stripped.
77///
78/// This is a host-agnostic version of [`parse_github_remote`], suitable for
79/// GitLab, Gitea, and other SCM providers.
80pub(crate) fn parse_remote_owner_repo(url: &str) -> Option<(String, String)> {
81    let url = url.trim();
82    if url.is_empty() {
83        return None;
84    }
85
86    // Strip trailing ".git" if present
87    let url = url.strip_suffix(".git").unwrap_or(url);
88
89    // HTTPS: https://host/owner/repo or https://host/group/subgroup/repo
90    if url.starts_with("https://") || url.starts_with("http://") {
91        // Strip scheme and host
92        let after_scheme = if let Some(rest) = url.strip_prefix("https://") {
93            rest
94        } else {
95            url.strip_prefix("http://")?
96        };
97        // Strip any credentials (user:pass@host or user@host)
98        let after_host = after_scheme.find('/').map(|i| &after_scheme[i + 1..])?;
99        // For nested groups (e.g. group/subgroup/repo), the owner is everything
100        // up to the last slash.
101        let last_slash = after_host.rfind('/')?;
102        let owner = &after_host[..last_slash];
103        let repo = &after_host[last_slash + 1..];
104        if !owner.is_empty() && !repo.is_empty() {
105            return Some((owner.to_string(), repo.to_string()));
106        }
107    }
108
109    // SSH: git@host:owner/repo or git@host:group/subgroup/repo
110    if let Some(colon_pos) = url.find(':') {
111        let before_colon = &url[..colon_pos];
112        // Ensure it looks like an SSH URL (contains @, no //)
113        if before_colon.contains('@') && !before_colon.contains("//") {
114            let path = &url[colon_pos + 1..];
115            let last_slash = path.rfind('/')?;
116            let owner = &path[..last_slash];
117            let repo = &path[last_slash + 1..];
118            if !owner.is_empty() && !repo.is_empty() {
119                return Some((owner.to_string(), repo.to_string()));
120            }
121        }
122    }
123
124    None
125}
126
127/// Convert a git remote URL into its web base (`https://host/owner/repo`),
128/// regardless of SCM host.
129///
130/// Accepts HTTPS (`https://host/owner/repo.git`) and SSH
131/// (`git@host:owner/repo.git`) forms, normalizes both to
132/// `https://host/owner/repo` (no `.git` suffix), and preserves nested
133/// groups (`group/subgroup/repo`). Returns `None` when the URL has no
134/// recognizable host or path.
135///
136/// This is the host-preserving counterpart of `parse_remote_owner_repo`:
137/// it keeps the host so callers (e.g. changelog compare-link footers) can
138/// build links against a self-hosted GitLab/Gitea instead of assuming
139/// `github.com`.
140pub fn parse_remote_web_base(url: &str) -> Option<String> {
141    let url = url.trim();
142    if url.is_empty() {
143        return None;
144    }
145    let url = url.strip_suffix(".git").unwrap_or(url);
146
147    // HTTPS/HTTP: normalize the scheme to https and drop any userinfo.
148    if let Some(rest) = url
149        .strip_prefix("https://")
150        .or_else(|| url.strip_prefix("http://"))
151    {
152        // Split host[:port]/path; drop credentials in the host segment.
153        let slash = rest.find('/')?;
154        let host_seg = &rest[..slash];
155        let path = &rest[slash + 1..];
156        let host = host_seg.rsplit('@').next().unwrap_or(host_seg);
157        if host.is_empty() || path.is_empty() {
158            return None;
159        }
160        return Some(format!("https://{}/{}", host, path));
161    }
162
163    // SSH: git@host:owner/repo
164    if let Some(colon_pos) = url.find(':') {
165        let before_colon = &url[..colon_pos];
166        if before_colon.contains('@') && !before_colon.contains("//") {
167            let host = before_colon.rsplit('@').next().unwrap_or(before_colon);
168            let path = &url[colon_pos + 1..];
169            if !host.is_empty() && !path.is_empty() {
170                return Some(format!("https://{}/{}", host, path));
171            }
172        }
173    }
174
175    None
176}
177
178/// Get the web base (`https://host/owner/repo`) for the `origin` remote
179/// configured in `cwd`, regardless of SCM host.
180///
181/// Path-taking helper used to build host-correct compare links (changelog
182/// footers) for self-hosted GitLab/Gitea as well as github.com.
183pub fn detect_remote_web_base_in(cwd: &Path) -> Result<String> {
184    let url = git_output_in(cwd, &["remote", "get-url", "origin"])?;
185    parse_remote_web_base(&url).ok_or_else(|| {
186        let safe = redact_url_credentials(&url);
187        anyhow::anyhow!("could not parse web base from remote URL: {}", safe)
188    })
189}
190
191/// Get the owner/repo from the `origin` remote configured in `cwd`,
192/// regardless of SCM host.
193///
194/// Uses `parse_remote_owner_repo` which works with any git hosting provider
195/// (GitHub, GitLab, Gitea, etc.).
196pub(crate) fn detect_owner_repo_in(cwd: &Path) -> Result<(String, String)> {
197    let url = git_output_in(cwd, &["remote", "get-url", "origin"])?;
198    parse_remote_owner_repo(&url).ok_or_else(|| {
199        // Strip inline userinfo before surfacing the URL.
200        let safe = redact_url_credentials(&url);
201        anyhow::anyhow!("could not parse owner/repo from remote URL: {}", safe)
202    })
203}