main
rs 234 lines 7.61 KB
Raw
1 //! Working-directory git awareness.
2 //!
3 //! The point of a `gh`-style CLI is that you rarely have to name the repo — it
4 //! reads the one you're standing in. This module answers two questions about
5 //! the current directory: which sigit.si repo does it point at, and what branch
6 //! is checked out.
7
8 use {
9 anyhow::{anyhow, Context, Result},
10 git2::Repository as GitRepository,
11 std::{fmt, path::Path},
12 };
13
14 /// An `owner/name` pair identifying a repository on sigit.si.
15 #[derive(Debug, Clone, PartialEq, Eq)]
16 pub struct RepoSlug {
17 pub owner: String,
18 pub name: String,
19 }
20
21 impl RepoSlug {
22 /// Parse the `owner/name` form users type into `--repo`.
23 pub fn parse(input: &str) -> Result<Self> {
24 let trimmed = input.trim().trim_matches('/');
25 let mut parts = trimmed.splitn(2, '/');
26 let owner = parts.next().unwrap_or_default().trim();
27 let name = parts.next().unwrap_or_default().trim();
28
29 if owner.is_empty() || name.is_empty() || name.contains('/') {
30 return Err(anyhow!(
31 "Expected a repository in OWNER/NAME form, got {input:?}."
32 ));
33 }
34
35 Ok(Self {
36 owner: owner.to_string(),
37 name: name.trim_end_matches(".git").to_string(),
38 })
39 }
40 }
41
42 impl fmt::Display for RepoSlug {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 write!(f, "{}/{}", self.owner, self.name)
45 }
46 }
47
48 /// Find the sigit.si repository the given directory belongs to.
49 ///
50 /// Remotes are searched in preference order — `origin`, then `sigit`, then
51 /// whatever else is configured — and only remotes whose host matches `host` are
52 /// considered, so a repo with both a GitHub and a sigit.si remote resolves to
53 /// the sigit.si one.
54 pub fn current_repo(dir: &Path, host: &str) -> Result<RepoSlug> {
55 let repository = GitRepository::discover(dir)
56 .context("Not inside a git repository. Pass --repo OWNER/NAME instead.")?;
57
58 let remotes = repository
59 .remotes()
60 .context("Could not read this repository's remotes.")?;
61
62 let names: Vec<String> = remotes.iter().flatten().map(str::to_string).collect();
63
64 for candidate in preferred_order(&names) {
65 let Ok(remote) = repository.find_remote(&candidate) else {
66 continue;
67 };
68 let Some(url) = remote.url() else { continue };
69 if let Some(slug) = slug_from_url(url, host) {
70 return Ok(slug);
71 }
72 }
73
74 Err(anyhow!(
75 "No {host} remote found in this repository. Pass --repo OWNER/NAME instead."
76 ))
77 }
78
79 /// The branch currently checked out, or `None` on a detached HEAD.
80 pub fn current_branch(dir: &Path) -> Option<String> {
81 let repository = GitRepository::discover(dir).ok()?;
82 let head = repository.head().ok()?;
83 if !head.is_branch() {
84 return None;
85 }
86 head.shorthand().map(str::to_string)
87 }
88
89 /// `origin` first, then `sigit`, then the rest in their configured order.
90 fn preferred_order(names: &[String]) -> Vec<String> {
91 let mut ordered = Vec::with_capacity(names.len());
92 for preferred in ["origin", "sigit"] {
93 if names.iter().any(|n| n == preferred) {
94 ordered.push(preferred.to_string());
95 }
96 }
97 for name in names {
98 if !ordered.contains(name) {
99 ordered.push(name.clone());
100 }
101 }
102 ordered
103 }
104
105 /// Pull `owner/name` out of a remote URL, but only when it points at `host`.
106 ///
107 /// Handles the three shapes git remotes come in: `https://host/owner/repo.git`,
108 /// `git@host:owner/repo.git`, and `ssh://git@host/owner/repo.git`.
109 fn slug_from_url(url: &str, host: &str) -> Option<RepoSlug> {
110 let url = url.trim();
111 let (url_host, path) = split_host_and_path(url)?;
112
113 if !hosts_match(&url_host, host) {
114 return None;
115 }
116
117 RepoSlug::parse(path.trim_matches('/')).ok()
118 }
119
120 fn split_host_and_path(url: &str) -> Option<(String, String)> {
121 if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
122 // scheme://[user@]host[:port]/path
123 let rest = rest.split_once('@').map_or(rest, |(_, after)| after);
124 let (authority, path) = rest.split_once('/')?;
125 return Some((authority.to_string(), path.to_string()));
126 }
127
128 // scp-like: [user@]host:path
129 let rest = url.split_once('@').map_or(url, |(_, after)| after);
130 let (authority, path) = rest.split_once(':')?;
131 Some((authority.to_string(), path.to_string()))
132 }
133
134 /// Compare hosts ignoring the port and a leading `www.`, so a dev remote on
135 /// `localhost:3000` matches a `localhost` base URL and vice versa.
136 fn hosts_match(a: &str, b: &str) -> bool {
137 fn normalize(host: &str) -> String {
138 let host = host.trim().to_ascii_lowercase();
139 let host = host
140 .rsplit_once("://")
141 .map_or(host.as_str(), |(_, rest)| rest);
142 let host = host.split('/').next().unwrap_or(host);
143 let host = host.split(':').next().unwrap_or(host);
144 host.trim_start_matches("www.").to_string()
145 }
146
147 normalize(a) == normalize(b)
148 }
149
150 #[cfg(test)]
151 mod tests {
152 use super::*;
153
154 #[test]
155 fn slugs_parse_from_the_owner_name_form() {
156 let slug = RepoSlug::parse("seto/sigit-si").unwrap();
157 assert_eq!(slug.owner, "seto");
158 assert_eq!(slug.name, "sigit-si");
159 assert_eq!(slug.to_string(), "seto/sigit-si");
160 }
161
162 #[test]
163 fn a_trailing_git_suffix_is_dropped() {
164 assert_eq!(
165 RepoSlug::parse("seto/sigit-si.git").unwrap().name,
166 "sigit-si"
167 );
168 }
169
170 #[test]
171 fn dotted_repo_names_survive() {
172 // The server allows names like "Qwen2.5-3B-Instruct-GGUF".
173 let slug = RepoSlug::parse("seto/Qwen2.5-3B-Instruct-GGUF").unwrap();
174 assert_eq!(slug.name, "Qwen2.5-3B-Instruct-GGUF");
175 }
176
177 #[test]
178 fn incomplete_slugs_are_rejected() {
179 assert!(RepoSlug::parse("sigit-si").is_err());
180 assert!(RepoSlug::parse("seto/").is_err());
181 assert!(RepoSlug::parse("/sigit-si").is_err());
182 assert!(RepoSlug::parse("a/b/c").is_err());
183 }
184
185 #[test]
186 fn https_remotes_resolve() {
187 let slug = slug_from_url("https://sigit.si/seto/sigit-si.git", "https://sigit.si").unwrap();
188 assert_eq!(slug.to_string(), "seto/sigit-si");
189 }
190
191 #[test]
192 fn scp_style_ssh_remotes_resolve() {
193 let slug = slug_from_url("git@sigit.si:seto/sigit-si.git", "sigit.si").unwrap();
194 assert_eq!(slug.to_string(), "seto/sigit-si");
195 }
196
197 #[test]
198 fn ssh_url_remotes_resolve() {
199 let slug = slug_from_url("ssh://git@sigit.si/seto/sigit-si.git", "sigit.si").unwrap();
200 assert_eq!(slug.to_string(), "seto/sigit-si");
201 }
202
203 #[test]
204 fn remotes_on_another_host_are_ignored() {
205 // The whole point: a repo mirrored from GitHub must not resolve to its
206 // GitHub remote when we asked for the sigit.si one.
207 assert!(slug_from_url("git@github.com:seto/sigit-si.git", "sigit.si").is_none());
208 assert!(
209 slug_from_url("https://github.com/seto/sigit-si.git", "https://sigit.si").is_none()
210 );
211 }
212
213 #[test]
214 fn dev_remotes_match_regardless_of_port() {
215 let slug = slug_from_url(
216 "http://localhost:3000/seto/demo.git",
217 "http://localhost:3000",
218 )
219 .unwrap();
220 assert_eq!(slug.to_string(), "seto/demo");
221 }
222
223 #[test]
224 fn origin_is_tried_before_other_remotes() {
225 let names = vec!["upstream".to_string(), "origin".to_string()];
226 assert_eq!(preferred_order(&names).first().unwrap(), "origin");
227 }
228
229 #[test]
230 fn every_remote_is_still_considered() {
231 let names = vec!["upstream".to_string(), "origin".to_string()];
232 assert_eq!(preferred_order(&names).len(), 2);
233 }
234 }