| 1 | //! Which repository a command is about: `--repo`, `G1T_REPO`, or the |
| 2 | //! current directory's git remotes. |
| 3 | |
| 4 | use std::fmt; |
| 5 | |
| 6 | use crate::error::{Error, Result}; |
| 7 | |
| 8 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 9 | pub struct Repo { |
| 10 | pub host: String, |
| 11 | pub owner: String, |
| 12 | pub name: String, |
| 13 | } |
| 14 | |
| 15 | impl fmt::Display for Repo { |
| 16 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 17 | write!(f, "{}/{}", self.owner, self.name) |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | /// The namespace pull requests' working copies live under: |
| 22 | /// `https://g1t.sh/pulls/<pull id>.git`. |
| 23 | pub const PULLS: &str = "pulls"; |
| 24 | |
| 25 | impl Repo { |
| 26 | /// `owner/name` or `host/owner/name`; a URL works too. |
| 27 | pub fn parse(input: &str, default_host: &str) -> Result<Repo> { |
| 28 | if let Some(found) = parse_remote(input) { |
| 29 | return Ok(found); |
| 30 | } |
| 31 | let trimmed = input.trim().trim_end_matches('/').trim_end_matches(".git"); |
| 32 | let parts: Vec<&str> = trimmed.split('/').collect(); |
| 33 | let valid = |s: &&str| !s.is_empty() && !s.contains(char::is_whitespace); |
| 34 | match parts.as_slice() { |
| 35 | [owner, name] if valid(owner) && valid(name) => Ok(Repo { |
| 36 | host: default_host.to_owned(), |
| 37 | owner: (*owner).to_owned(), |
| 38 | name: (*name).to_owned(), |
| 39 | }), |
| 40 | [host, owner, name] if valid(host) && valid(owner) && valid(name) => Ok(Repo { |
| 41 | host: host.to_lowercase(), |
| 42 | owner: (*owner).to_owned(), |
| 43 | name: (*name).to_owned(), |
| 44 | }), |
| 45 | _ => Err(Error::usage(format!( |
| 46 | "`{input}` does not name a repository: write it as OWNER/NAME or HOST/OWNER/NAME." |
| 47 | ))), |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | /// The pull request id, when this is a pull request's working copy. |
| 52 | pub fn pull_copy(&self) -> Option<&str> { |
| 53 | (self.owner == PULLS).then_some(self.name.as_str()) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | /// A git remote's URL as a repository: `https://host/owner/name(.git)`, |
| 58 | /// `ssh://git@host/owner/name.git` or `git@host:owner/name.git`. |
| 59 | pub fn parse_remote(url: &str) -> Option<Repo> { |
| 60 | let url = url.trim(); |
| 61 | let (host, path) = if let Some((scheme, rest)) = url.split_once("://") { |
| 62 | if !matches!(scheme, "http" | "https" | "ssh" | "git") { |
| 63 | return None; |
| 64 | } |
| 65 | let (authority, path) = rest.split_once('/')?; |
| 66 | let host = authority.rsplit('@').next()?; |
| 67 | // ssh's port is not part of the host's name; http's is. |
| 68 | let host = if scheme == "ssh" { |
| 69 | host.split(':').next()? |
| 70 | } else { |
| 71 | host |
| 72 | }; |
| 73 | (host, path) |
| 74 | } else { |
| 75 | // scp-like: [user@]host:path |
| 76 | let (left, path) = url.split_once(':')?; |
| 77 | // C:/code/web is a Windows path, as git reads it too. |
| 78 | let drive = left.len() == 1 && left.chars().all(|c| c.is_ascii_alphabetic()); |
| 79 | if drive || left.contains('/') || path.starts_with("//") { |
| 80 | return None; |
| 81 | } |
| 82 | (left.rsplit('@').next()?, path) |
| 83 | }; |
| 84 | let path = path.trim_matches('/').trim_end_matches(".git"); |
| 85 | let mut parts = path.split('/'); |
| 86 | let (owner, name) = (parts.next()?, parts.next()?); |
| 87 | if parts.next().is_some() || owner.is_empty() || name.is_empty() || host.is_empty() { |
| 88 | return None; |
| 89 | } |
| 90 | Some(Repo { |
| 91 | host: host.to_lowercase(), |
| 92 | owner: owner.to_owned(), |
| 93 | name: name.to_owned(), |
| 94 | }) |
| 95 | } |
| 96 | |
| 97 | /// Picks the repository from `remotes` on one of `hosts`: `upstream` first, |
| 98 | /// then `origin`, then the first one listed. |
| 99 | pub fn from_remotes(remotes: &[(String, String)], hosts: &[String]) -> Option<(String, Repo)> { |
| 100 | let found: Vec<(String, Repo)> = remotes |
| 101 | .iter() |
| 102 | .filter_map(|(name, url)| parse_remote(url).map(|r| (name.clone(), r))) |
| 103 | .filter(|(_, r)| hosts.iter().any(|h| h == &r.host)) |
| 104 | .collect(); |
| 105 | for preferred in ["upstream", "origin"] { |
| 106 | if let Some(hit) = found.iter().find(|(n, _)| n == preferred) { |
| 107 | return Some(hit.clone()); |
| 108 | } |
| 109 | } |
| 110 | found.into_iter().next() |
| 111 | } |
| 112 | |
| 113 | #[cfg(test)] |
| 114 | mod tests { |
| 115 | use super::*; |
| 116 | |
| 117 | fn r(host: &str, owner: &str, name: &str) -> Repo { |
| 118 | Repo { |
| 119 | host: host.into(), |
| 120 | owner: owner.into(), |
| 121 | name: name.into(), |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | #[test] |
| 126 | fn remotes_parse_in_every_form() { |
| 127 | assert_eq!( |
| 128 | parse_remote("https://g1t.sh/acme/web.git"), |
| 129 | Some(r("g1t.sh", "acme", "web")) |
| 130 | ); |
| 131 | assert_eq!( |
| 132 | parse_remote("https://ana@g1t.sh/acme/web"), |
| 133 | Some(r("g1t.sh", "acme", "web")) |
| 134 | ); |
| 135 | assert_eq!( |
| 136 | parse_remote("http://localhost:8787/acme/web.git"), |
| 137 | Some(r("localhost:8787", "acme", "web")) |
| 138 | ); |
| 139 | assert_eq!( |
| 140 | parse_remote("ssh://git@g1t.sh:22/acme/web.git"), |
| 141 | Some(r("g1t.sh", "acme", "web")) |
| 142 | ); |
| 143 | assert_eq!( |
| 144 | parse_remote("git@g1t.sh:acme/web.git"), |
| 145 | Some(r("g1t.sh", "acme", "web")) |
| 146 | ); |
| 147 | assert_eq!(parse_remote("https://g1t.sh/acme/web/tree/main"), None); |
| 148 | assert_eq!(parse_remote("C:/code/web"), None); |
| 149 | assert_eq!(parse_remote("/srv/git/web.git"), None); |
| 150 | } |
| 151 | |
| 152 | #[test] |
| 153 | fn names_parse_with_and_without_a_host() { |
| 154 | assert_eq!( |
| 155 | Repo::parse("acme/web", "g1t.sh").unwrap(), |
| 156 | r("g1t.sh", "acme", "web") |
| 157 | ); |
| 158 | assert_eq!( |
| 159 | Repo::parse("git.example.com/acme/web", "g1t.sh").unwrap(), |
| 160 | r("git.example.com", "acme", "web") |
| 161 | ); |
| 162 | assert_eq!( |
| 163 | Repo::parse("https://g1t.sh/acme/web", "x").unwrap(), |
| 164 | r("g1t.sh", "acme", "web") |
| 165 | ); |
| 166 | assert!(Repo::parse("web", "g1t.sh").is_err()); |
| 167 | assert!(Repo::parse("a/b/c/d", "g1t.sh").is_err()); |
| 168 | } |
| 169 | |
| 170 | #[test] |
| 171 | fn upstream_then_origin_then_the_first() { |
| 172 | let hosts = vec!["g1t.sh".to_owned()]; |
| 173 | let remotes = vec![ |
| 174 | ("mine".to_owned(), "https://g1t.sh/ana/web.git".to_owned()), |
| 175 | ( |
| 176 | "origin".to_owned(), |
| 177 | "https://g1t.sh/acme/web.git".to_owned(), |
| 178 | ), |
| 179 | ( |
| 180 | "gh".to_owned(), |
| 181 | "https://example.com/acme/web.git".to_owned(), |
| 182 | ), |
| 183 | ]; |
| 184 | assert_eq!( |
| 185 | from_remotes(&remotes, &hosts).unwrap().1, |
| 186 | r("g1t.sh", "acme", "web") |
| 187 | ); |
| 188 | let remotes = vec![("mine".to_owned(), "https://g1t.sh/ana/web.git".to_owned())]; |
| 189 | assert_eq!( |
| 190 | from_remotes(&remotes, &hosts).unwrap().1, |
| 191 | r("g1t.sh", "ana", "web") |
| 192 | ); |
| 193 | let remotes = vec![( |
| 194 | "gh".to_owned(), |
| 195 | "https://example.com/acme/web.git".to_owned(), |
| 196 | )]; |
| 197 | assert_eq!(from_remotes(&remotes, &hosts), None); |
| 198 | } |
| 199 | } |