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}