1use anyhow::{Context, Result};
4use git2::{BranchType, Repository};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct RemoteInfo {
10 pub name: String,
12 pub uri: String,
14 pub main_branch: String,
16}
17
18impl RemoteInfo {
19 pub fn get_all_remotes(repo: &Repository) -> Result<Vec<Self>> {
21 let mut remotes = Vec::new();
22 let remote_names = repo.remotes().context("Failed to get remote names")?;
23
24 let repo_root = repo.workdir().unwrap_or_else(|| repo.path());
29
30 for name in remote_names.iter().flatten().flatten() {
31 if let Ok(remote) = repo.find_remote(name) {
32 let uri = remote.url().unwrap_or("").to_string();
33 let main_branch = Self::detect_main_branch(repo, name, repo_root)?;
34
35 remotes.push(Self {
36 name: name.to_string(),
37 uri,
38 main_branch,
39 });
40 }
41 }
42
43 Ok(remotes)
44 }
45
46 fn detect_main_branch(
51 repo: &Repository,
52 remote_name: &str,
53 repo_root: &std::path::Path,
54 ) -> Result<String> {
55 if let Some(branch_name) = Self::main_branch_from_remote_head(repo, remote_name) {
57 return Ok(branch_name);
58 }
59
60 if let Ok(remote) = repo.find_remote(remote_name) {
62 if let Ok(uri) = remote.url() {
63 if uri.contains("github.com") {
64 if let Ok(main_branch) = Self::get_github_default_branch(uri, repo_root) {
65 return Ok(main_branch);
66 }
67 }
68 }
69 }
70
71 if let Some(branch_name) = Self::main_branch_from_common_names(repo, remote_name) {
73 return Ok(branch_name);
74 }
75
76 let branch_iter = repo.branches(Some(BranchType::Remote))?;
78 for branch_result in branch_iter {
79 let (branch, _) = branch_result?;
80 if let Some(name) = branch.name()? {
81 if name.starts_with(&format!("{remote_name}/")) {
82 let branch_name = name
83 .strip_prefix(&format!("{remote_name}/"))
84 .unwrap_or(name);
85 return Ok(branch_name.to_string());
86 }
87 }
88 }
89
90 Ok("unknown".to_string())
92 }
93
94 pub(crate) fn detect_main_branch_local(repo: &Repository, remote_name: &str) -> Option<String> {
103 Self::main_branch_from_remote_head(repo, remote_name)
104 .or_else(|| Self::main_branch_from_common_names(repo, remote_name))
105 }
106
107 fn main_branch_from_remote_head(repo: &Repository, remote_name: &str) -> Option<String> {
109 let head_ref_name = format!("refs/remotes/{remote_name}/HEAD");
110 let head_ref = repo.find_reference(&head_ref_name).ok()?;
111 let target = head_ref.symbolic_target().ok()??;
112 target
114 .strip_prefix(&format!("refs/remotes/{remote_name}/"))
115 .map(ToString::to_string)
116 }
117
118 fn main_branch_from_common_names(repo: &Repository, remote_name: &str) -> Option<String> {
121 let common_branches = ["main", "master", "develop"];
122
123 if remote_name == "origin" {
125 for branch_name in &common_branches {
126 let reference_name = format!("refs/remotes/origin/{branch_name}");
127 if repo.find_reference(&reference_name).is_ok() {
128 return Some((*branch_name).to_string());
129 }
130 }
131 } else {
132 for branch_name in &common_branches {
134 let origin_reference = format!("refs/remotes/origin/{branch_name}");
135 if repo.find_reference(&origin_reference).is_ok() {
136 return Some((*branch_name).to_string());
137 }
138 }
139
140 for branch_name in &common_branches {
142 let reference_name = format!("refs/remotes/{remote_name}/{branch_name}");
143 if repo.find_reference(&reference_name).is_ok() {
144 return Some((*branch_name).to_string());
145 }
146 }
147 }
148
149 None
150 }
151
152 fn get_github_default_branch(uri: &str, repo_root: &std::path::Path) -> Result<String> {
159 use std::process::Command;
160
161 let repo_name = Self::extract_github_repo_name(uri)?;
163
164 let output = Command::new("gh")
166 .args([
167 "repo",
168 "view",
169 &repo_name,
170 "--json",
171 "defaultBranchRef",
172 "--jq",
173 ".defaultBranchRef.name",
174 ])
175 .current_dir(repo_root)
176 .output();
177
178 match output {
179 Ok(output) if output.status.success() => {
180 let branch_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
181 if !branch_name.is_empty() && branch_name != "null" {
182 Ok(branch_name)
183 } else {
184 anyhow::bail!("GitHub CLI returned empty or null branch name")
185 }
186 }
187 _ => anyhow::bail!("Failed to get default branch from GitHub CLI"),
188 }
189 }
190
191 fn extract_github_repo_name(uri: &str) -> Result<String> {
193 let repo_name = if uri.starts_with("git@github.com:") {
195 uri.strip_prefix("git@github.com:")
197 .and_then(|s| s.strip_suffix(".git"))
198 .unwrap_or(uri.strip_prefix("git@github.com:").unwrap_or(uri))
199 } else if uri.contains("github.com") {
200 uri.split("github.com/")
202 .nth(1)
203 .and_then(|s| s.strip_suffix(".git"))
204 .unwrap_or(uri.split("github.com/").nth(1).unwrap_or(uri))
205 } else {
206 anyhow::bail!("Not a GitHub URI: {uri}");
207 };
208
209 if repo_name.split('/').count() != 2 {
210 anyhow::bail!("Invalid GitHub repository format: {repo_name}");
211 }
212
213 Ok(repo_name.to_string())
214 }
215}
216
217#[cfg(test)]
218#[allow(clippy::unwrap_used, clippy::expect_used)]
219mod tests {
220 use super::*;
221
222 #[test]
225 fn ssh_url() {
226 let result = RemoteInfo::extract_github_repo_name("git@github.com:owner/repo.git");
227 assert_eq!(result.unwrap(), "owner/repo");
228 }
229
230 #[test]
231 fn https_url() {
232 let result = RemoteInfo::extract_github_repo_name("https://github.com/owner/repo.git");
233 assert_eq!(result.unwrap(), "owner/repo");
234 }
235
236 #[test]
237 fn https_url_no_git_suffix() {
238 let result = RemoteInfo::extract_github_repo_name("https://github.com/owner/repo");
239 assert_eq!(result.unwrap(), "owner/repo");
240 }
241
242 #[test]
243 fn ssh_url_no_git_suffix() {
244 let result = RemoteInfo::extract_github_repo_name("git@github.com:owner/repo");
245 assert_eq!(result.unwrap(), "owner/repo");
246 }
247
248 #[test]
249 fn non_github_url_fails() {
250 let result = RemoteInfo::extract_github_repo_name("git@gitlab.com:owner/repo.git");
251 assert!(result.is_err());
252 assert!(result.unwrap_err().to_string().contains("Not a GitHub URI"));
253 }
254
255 #[test]
256 fn invalid_format_fails() {
257 let result = RemoteInfo::extract_github_repo_name("git@github.com:invalid");
258 assert!(result.is_err());
259 assert!(result
260 .unwrap_err()
261 .to_string()
262 .contains("Invalid GitHub repository format"));
263 }
264
265 mod prop {
268 use super::*;
269 use proptest::prelude::*;
270
271 proptest! {
272 #[test]
273 fn ssh_url_extracts_repo(
274 owner in "[a-z]{3,10}",
275 repo in "[a-z]{3,10}",
276 ) {
277 let url = format!("git@github.com:{owner}/{repo}.git");
278 let result = RemoteInfo::extract_github_repo_name(&url).unwrap();
279 prop_assert_eq!(result, format!("{owner}/{repo}"));
280 }
281
282 #[test]
283 fn https_url_extracts_repo(
284 owner in "[a-z]{3,10}",
285 repo in "[a-z]{3,10}",
286 ) {
287 let url = format!("https://github.com/{owner}/{repo}.git");
288 let result = RemoteInfo::extract_github_repo_name(&url).unwrap();
289 prop_assert_eq!(result, format!("{owner}/{repo}"));
290 }
291
292 #[test]
293 fn non_github_url_errors(
294 host in "(gitlab|bitbucket|codeberg)",
295 path in "[a-z]{3,10}/[a-z]{3,10}",
296 ) {
297 let url = format!("git@{host}.com:{path}.git");
298 prop_assert!(RemoteInfo::extract_github_repo_name(&url).is_err());
299 }
300 }
301 }
302
303 fn repo_with_commit() -> (tempfile::TempDir, Repository, git2::Oid) {
308 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
309 std::fs::create_dir_all(&tmp_root).unwrap();
310 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
311 let repo = Repository::init(temp_dir.path()).unwrap();
312 let oid = {
313 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
314 let tree_id = repo.index().unwrap().write_tree().unwrap();
315 let tree = repo.find_tree(tree_id).unwrap();
316 repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
317 .unwrap()
318 };
319 (temp_dir, repo, oid)
320 }
321
322 #[test]
323 fn local_detection_prefers_symbolic_head() {
324 let (_dir, repo, oid) = repo_with_commit();
325 repo.reference("refs/remotes/origin/trunk", oid, false, "test")
326 .unwrap();
327 repo.reference_symbolic(
328 "refs/remotes/origin/HEAD",
329 "refs/remotes/origin/trunk",
330 false,
331 "test",
332 )
333 .unwrap();
334 let result = RemoteInfo::detect_main_branch_local(&repo, "origin");
335 assert_eq!(result.as_deref(), Some("trunk"));
336 }
337
338 #[test]
339 fn local_detection_falls_back_to_common_names() {
340 let (_dir, repo, oid) = repo_with_commit();
341 repo.reference("refs/remotes/origin/master", oid, false, "test")
342 .unwrap();
343 let result = RemoteInfo::detect_main_branch_local(&repo, "origin");
344 assert_eq!(result.as_deref(), Some("master"));
345 }
346
347 #[test]
348 fn local_detection_returns_none_for_uncommon_branches() {
349 let (_dir, repo, oid) = repo_with_commit();
352 repo.reference("refs/remotes/origin/exotic", oid, false, "test")
353 .unwrap();
354 assert_eq!(RemoteInfo::detect_main_branch_local(&repo, "origin"), None);
355 }
356}