Skip to main content

shipshape_core/
vcs.rs

1//! Version-control helpers shared across domains — the neutral home for git
2//! remote/URL parsing that both the readiness [audit](crate::audit) and the
3//! release [coordinator](crate::release::coordinator) depend on.
4//!
5//! Kept out of any single domain module so the dependency runs one way (each
6//! consumer → `vcs`) rather than one domain reaching into another's internals.
7
8/// Every recognized way a GitHub remote URL prefixes the `owner/repo` tail. The
9/// match is a **prefix**, not a substring, so a non-GitHub host that merely
10/// contains `github.com` in its path (`https://mirror.example/github.com/o/r`)
11/// is rejected rather than mis-parsed into a GitHub slug.
12const GITHUB_PREFIXES: &[&str] = &[
13    "git@github.com:",       // scp-like (the common SSH form)
14    "ssh://git@github.com/", // explicit ssh:// SSH form
15    "ssh://github.com/",     // ssh:// without a user
16    "https://github.com/",   //
17    "http://github.com/",    //
18    "git://github.com/",     //
19    "github.com:",           // bare scp-like
20    "github.com/",           // bare
21];
22
23/// Parse `owner/repo` out of a GitHub remote URL — the SSH (`git@github.com:o/r`,
24/// `ssh://git@github.com/o/r`), HTTPS, and `git://` forms. `None` for a
25/// non-GitHub host or an unrecognizable URL. The parser anchors on a known host
26/// prefix (never a bare `find`), so a lookalike host is never accepted; a wrong
27/// parse would in any case only yield a failed `gh api` ⇒ `unknown`, never a
28/// false `Absent`.
29pub fn parse_github_slug(url: &str) -> Option<String> {
30    let tail = GITHUB_PREFIXES.iter().find_map(|p| url.strip_prefix(p))?;
31    // Trim a trailing slash BEFORE stripping `.git` so `.../repo.git/` and
32    // `.../repo/` both reduce to `repo` (strip order matters).
33    let tail = tail.trim_end_matches('/');
34    let tail = tail.strip_suffix(".git").unwrap_or(tail);
35    let tail = tail.trim_end_matches('/');
36    let mut parts = tail.splitn(3, '/');
37    let owner = parts.next().filter(|s| !s.is_empty())?;
38    let repo = parts.next().filter(|s| !s.is_empty())?;
39    // Reject a trailing path segment (`owner/repo/extra`) — not a bare slug.
40    if parts.next().is_some() {
41        return None;
42    }
43    Some(format!("{owner}/{repo}"))
44}
45
46#[cfg(test)]
47mod tests {
48    use super::parse_github_slug;
49
50    #[test]
51    fn parses_github_slugs_across_url_forms() {
52        assert_eq!(
53            parse_github_slug("git@github.com:acme/tool.git"),
54            Some("acme/tool".to_string())
55        );
56        assert_eq!(
57            parse_github_slug("https://github.com/acme/tool.git"),
58            Some("acme/tool".to_string())
59        );
60        assert_eq!(
61            parse_github_slug("https://github.com/acme/tool"),
62            Some("acme/tool".to_string())
63        );
64        assert_eq!(
65            parse_github_slug("git://github.com/acme/tool.git"),
66            Some("acme/tool".to_string())
67        );
68        assert_eq!(
69            parse_github_slug("ssh://git@github.com/acme/tool.git"),
70            Some("acme/tool".to_string())
71        );
72        // A trailing slash after `.git` still reduces to the bare slug.
73        assert_eq!(
74            parse_github_slug("https://github.com/acme/tool.git/"),
75            Some("acme/tool".to_string())
76        );
77        assert_eq!(
78            parse_github_slug("https://github.com/acme/tool/"),
79            Some("acme/tool".to_string())
80        );
81
82        // Non-GitHub host, lookalike hosts, and over-long paths are rejected.
83        assert_eq!(parse_github_slug("git@gitlab.com:acme/tool.git"), None);
84        assert_eq!(
85            parse_github_slug("https://mirror.example.com/github.com/acme/tool.git"),
86            None
87        );
88        assert_eq!(
89            parse_github_slug("https://github.com.evil.example/acme/tool"),
90            None
91        );
92        assert_eq!(
93            parse_github_slug("https://github.com/acme/tool/tree/main"),
94            None
95        );
96        assert_eq!(parse_github_slug("https://github.com/acme"), None);
97    }
98}