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 let head_ref_name = format!("refs/remotes/{remote_name}/HEAD");
57 if let Ok(head_ref) = repo.find_reference(&head_ref_name) {
58 if let Ok(Some(target)) = head_ref.symbolic_target() {
59 if let Some(branch_name) =
61 target.strip_prefix(&format!("refs/remotes/{remote_name}/"))
62 {
63 return Ok(branch_name.to_string());
64 }
65 }
66 }
67
68 if let Ok(remote) = repo.find_remote(remote_name) {
70 if let Ok(uri) = remote.url() {
71 if uri.contains("github.com") {
72 if let Ok(main_branch) = Self::get_github_default_branch(uri, repo_root) {
73 return Ok(main_branch);
74 }
75 }
76 }
77 }
78
79 let common_branches = ["main", "master", "develop"];
81
82 if remote_name == "origin" {
84 for branch_name in &common_branches {
85 let reference_name = format!("refs/remotes/origin/{branch_name}");
86 if repo.find_reference(&reference_name).is_ok() {
87 return Ok((*branch_name).to_string());
88 }
89 }
90 } else {
91 for branch_name in &common_branches {
93 let origin_reference = format!("refs/remotes/origin/{branch_name}");
94 if repo.find_reference(&origin_reference).is_ok() {
95 return Ok((*branch_name).to_string());
96 }
97 }
98
99 for branch_name in &common_branches {
101 let reference_name = format!("refs/remotes/{remote_name}/{branch_name}");
102 if repo.find_reference(&reference_name).is_ok() {
103 return Ok((*branch_name).to_string());
104 }
105 }
106 }
107
108 let branch_iter = repo.branches(Some(BranchType::Remote))?;
110 for branch_result in branch_iter {
111 let (branch, _) = branch_result?;
112 if let Some(name) = branch.name()? {
113 if name.starts_with(&format!("{remote_name}/")) {
114 let branch_name = name
115 .strip_prefix(&format!("{remote_name}/"))
116 .unwrap_or(name);
117 return Ok(branch_name.to_string());
118 }
119 }
120 }
121
122 Ok("unknown".to_string())
124 }
125
126 fn get_github_default_branch(uri: &str, repo_root: &std::path::Path) -> Result<String> {
133 use std::process::Command;
134
135 let repo_name = Self::extract_github_repo_name(uri)?;
137
138 let output = Command::new("gh")
140 .args([
141 "repo",
142 "view",
143 &repo_name,
144 "--json",
145 "defaultBranchRef",
146 "--jq",
147 ".defaultBranchRef.name",
148 ])
149 .current_dir(repo_root)
150 .output();
151
152 match output {
153 Ok(output) if output.status.success() => {
154 let branch_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
155 if !branch_name.is_empty() && branch_name != "null" {
156 Ok(branch_name)
157 } else {
158 anyhow::bail!("GitHub CLI returned empty or null branch name")
159 }
160 }
161 _ => anyhow::bail!("Failed to get default branch from GitHub CLI"),
162 }
163 }
164
165 fn extract_github_repo_name(uri: &str) -> Result<String> {
167 let repo_name = if uri.starts_with("git@github.com:") {
169 uri.strip_prefix("git@github.com:")
171 .and_then(|s| s.strip_suffix(".git"))
172 .unwrap_or(uri.strip_prefix("git@github.com:").unwrap_or(uri))
173 } else if uri.contains("github.com") {
174 uri.split("github.com/")
176 .nth(1)
177 .and_then(|s| s.strip_suffix(".git"))
178 .unwrap_or(uri.split("github.com/").nth(1).unwrap_or(uri))
179 } else {
180 anyhow::bail!("Not a GitHub URI: {uri}");
181 };
182
183 if repo_name.split('/').count() != 2 {
184 anyhow::bail!("Invalid GitHub repository format: {repo_name}");
185 }
186
187 Ok(repo_name.to_string())
188 }
189}
190
191#[cfg(test)]
192#[allow(clippy::unwrap_used, clippy::expect_used)]
193mod tests {
194 use super::*;
195
196 #[test]
199 fn ssh_url() {
200 let result = RemoteInfo::extract_github_repo_name("git@github.com:owner/repo.git");
201 assert_eq!(result.unwrap(), "owner/repo");
202 }
203
204 #[test]
205 fn https_url() {
206 let result = RemoteInfo::extract_github_repo_name("https://github.com/owner/repo.git");
207 assert_eq!(result.unwrap(), "owner/repo");
208 }
209
210 #[test]
211 fn https_url_no_git_suffix() {
212 let result = RemoteInfo::extract_github_repo_name("https://github.com/owner/repo");
213 assert_eq!(result.unwrap(), "owner/repo");
214 }
215
216 #[test]
217 fn ssh_url_no_git_suffix() {
218 let result = RemoteInfo::extract_github_repo_name("git@github.com:owner/repo");
219 assert_eq!(result.unwrap(), "owner/repo");
220 }
221
222 #[test]
223 fn non_github_url_fails() {
224 let result = RemoteInfo::extract_github_repo_name("git@gitlab.com:owner/repo.git");
225 assert!(result.is_err());
226 assert!(result.unwrap_err().to_string().contains("Not a GitHub URI"));
227 }
228
229 #[test]
230 fn invalid_format_fails() {
231 let result = RemoteInfo::extract_github_repo_name("git@github.com:invalid");
232 assert!(result.is_err());
233 assert!(result
234 .unwrap_err()
235 .to_string()
236 .contains("Invalid GitHub repository format"));
237 }
238
239 mod prop {
242 use super::*;
243 use proptest::prelude::*;
244
245 proptest! {
246 #[test]
247 fn ssh_url_extracts_repo(
248 owner in "[a-z]{3,10}",
249 repo in "[a-z]{3,10}",
250 ) {
251 let url = format!("git@github.com:{owner}/{repo}.git");
252 let result = RemoteInfo::extract_github_repo_name(&url).unwrap();
253 prop_assert_eq!(result, format!("{owner}/{repo}"));
254 }
255
256 #[test]
257 fn https_url_extracts_repo(
258 owner in "[a-z]{3,10}",
259 repo in "[a-z]{3,10}",
260 ) {
261 let url = format!("https://github.com/{owner}/{repo}.git");
262 let result = RemoteInfo::extract_github_repo_name(&url).unwrap();
263 prop_assert_eq!(result, format!("{owner}/{repo}"));
264 }
265
266 #[test]
267 fn non_github_url_errors(
268 host in "(gitlab|bitbucket|codeberg)",
269 path in "[a-z]{3,10}/[a-z]{3,10}",
270 ) {
271 let url = format!("git@{host}.com:{path}.git");
272 prop_assert!(RemoteInfo::extract_github_repo_name(&url).is_err());
273 }
274 }
275 }
276}