| 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 | } |