Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

199 linesCodeBlameRaw
1//! Which repository a command is about: `--repo`, `G1T_REPO`, or the
2//! current directory's git remotes.
3
4use std::fmt;
5
6use crate::error::{Error, Result};
7
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct Repo {
10 pub host: String,
11 pub owner: String,
12 pub name: String,
13}
14
15impl 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`.
23pub const PULLS: &str = "pulls";
24
25impl 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`.
59pub 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.
99pub 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)]
114mod 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}