Skip to main content

omni_dev/git/
remote.rs

1//! Git remote operations.
2
3use anyhow::{Context, Result};
4use git2::{BranchType, Repository};
5use serde::{Deserialize, Serialize};
6
7/// Remote repository information.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct RemoteInfo {
10    /// Name of the remote (e.g., "origin", "upstream").
11    pub name: String,
12    /// URI of the remote repository.
13    pub uri: String,
14    /// Detected main branch name for this remote.
15    pub main_branch: String,
16}
17
18impl RemoteInfo {
19    /// Returns all remotes for a repository.
20    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        for name in remote_names.iter().flatten() {
25            if let Ok(remote) = repo.find_remote(name) {
26                let uri = remote.url().unwrap_or("").to_string();
27                let main_branch = Self::detect_main_branch(repo, name)?;
28
29                remotes.push(Self {
30                    name: name.to_string(),
31                    uri,
32                    main_branch,
33                });
34            }
35        }
36
37        Ok(remotes)
38    }
39
40    /// Detects the main branch for a remote.
41    fn detect_main_branch(repo: &Repository, remote_name: &str) -> Result<String> {
42        // First try to get the remote HEAD reference
43        let head_ref_name = format!("refs/remotes/{remote_name}/HEAD");
44        if let Ok(head_ref) = repo.find_reference(&head_ref_name) {
45            if let Some(target) = head_ref.symbolic_target() {
46                // Extract branch name from refs/remotes/origin/main
47                if let Some(branch_name) =
48                    target.strip_prefix(&format!("refs/remotes/{remote_name}/"))
49                {
50                    return Ok(branch_name.to_string());
51                }
52            }
53        }
54
55        // Try using GitHub CLI for GitHub repositories
56        if let Ok(remote) = repo.find_remote(remote_name) {
57            if let Some(uri) = remote.url() {
58                if uri.contains("github.com") {
59                    if let Ok(main_branch) = Self::get_github_default_branch(uri) {
60                        return Ok(main_branch);
61                    }
62                }
63            }
64        }
65
66        // Fallback to checking common branch names, preferring origin remote
67        let common_branches = ["main", "master", "develop"];
68
69        // First, check if this is the origin remote or if origin remote branches exist
70        if remote_name == "origin" {
71            for branch_name in &common_branches {
72                let reference_name = format!("refs/remotes/origin/{branch_name}");
73                if repo.find_reference(&reference_name).is_ok() {
74                    return Ok((*branch_name).to_string());
75                }
76            }
77        } else {
78            // For non-origin remotes, first check if origin has these branches
79            for branch_name in &common_branches {
80                let origin_reference = format!("refs/remotes/origin/{branch_name}");
81                if repo.find_reference(&origin_reference).is_ok() {
82                    return Ok((*branch_name).to_string());
83                }
84            }
85
86            // Then check the actual remote
87            for branch_name in &common_branches {
88                let reference_name = format!("refs/remotes/{remote_name}/{branch_name}");
89                if repo.find_reference(&reference_name).is_ok() {
90                    return Ok((*branch_name).to_string());
91                }
92            }
93        }
94
95        // If no common branch found, try to find any branch
96        let branch_iter = repo.branches(Some(BranchType::Remote))?;
97        for branch_result in branch_iter {
98            let (branch, _) = branch_result?;
99            if let Some(name) = branch.name()? {
100                if name.starts_with(&format!("{remote_name}/")) {
101                    let branch_name = name
102                        .strip_prefix(&format!("{remote_name}/"))
103                        .unwrap_or(name);
104                    return Ok(branch_name.to_string());
105                }
106            }
107        }
108
109        // If still no branch found, return "unknown"
110        Ok("unknown".to_string())
111    }
112
113    /// Returns the default branch from GitHub using gh CLI.
114    fn get_github_default_branch(uri: &str) -> Result<String> {
115        use std::process::Command;
116
117        // Extract repository name from URI
118        let repo_name = Self::extract_github_repo_name(uri)?;
119
120        // Use gh CLI to get default branch
121        let output = Command::new("gh")
122            .args([
123                "repo",
124                "view",
125                &repo_name,
126                "--json",
127                "defaultBranchRef",
128                "--jq",
129                ".defaultBranchRef.name",
130            ])
131            .output();
132
133        match output {
134            Ok(output) if output.status.success() => {
135                let branch_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
136                if !branch_name.is_empty() && branch_name != "null" {
137                    Ok(branch_name)
138                } else {
139                    anyhow::bail!("GitHub CLI returned empty or null branch name")
140                }
141            }
142            _ => anyhow::bail!("Failed to get default branch from GitHub CLI"),
143        }
144    }
145
146    /// Extracts GitHub repository name from URI.
147    fn extract_github_repo_name(uri: &str) -> Result<String> {
148        // Handle both SSH and HTTPS GitHub URIs
149        let repo_name = if uri.starts_with("git@github.com:") {
150            // SSH format: git@github.com:owner/repo.git
151            uri.strip_prefix("git@github.com:")
152                .and_then(|s| s.strip_suffix(".git"))
153                .unwrap_or(uri.strip_prefix("git@github.com:").unwrap_or(uri))
154        } else if uri.contains("github.com") {
155            // HTTPS format: https://github.com/owner/repo.git
156            uri.split("github.com/")
157                .nth(1)
158                .and_then(|s| s.strip_suffix(".git"))
159                .unwrap_or(uri.split("github.com/").nth(1).unwrap_or(uri))
160        } else {
161            anyhow::bail!("Not a GitHub URI: {uri}");
162        };
163
164        if repo_name.split('/').count() != 2 {
165            anyhow::bail!("Invalid GitHub repository format: {repo_name}");
166        }
167
168        Ok(repo_name.to_string())
169    }
170}
171
172#[cfg(test)]
173#[allow(clippy::unwrap_used, clippy::expect_used)]
174mod tests {
175    use super::*;
176
177    // ── extract_github_repo_name ─────────────────────────────────────
178
179    #[test]
180    fn ssh_url() {
181        let result = RemoteInfo::extract_github_repo_name("git@github.com:owner/repo.git");
182        assert_eq!(result.unwrap(), "owner/repo");
183    }
184
185    #[test]
186    fn https_url() {
187        let result = RemoteInfo::extract_github_repo_name("https://github.com/owner/repo.git");
188        assert_eq!(result.unwrap(), "owner/repo");
189    }
190
191    #[test]
192    fn https_url_no_git_suffix() {
193        let result = RemoteInfo::extract_github_repo_name("https://github.com/owner/repo");
194        assert_eq!(result.unwrap(), "owner/repo");
195    }
196
197    #[test]
198    fn ssh_url_no_git_suffix() {
199        let result = RemoteInfo::extract_github_repo_name("git@github.com:owner/repo");
200        assert_eq!(result.unwrap(), "owner/repo");
201    }
202
203    #[test]
204    fn non_github_url_fails() {
205        let result = RemoteInfo::extract_github_repo_name("git@gitlab.com:owner/repo.git");
206        assert!(result.is_err());
207        assert!(result.unwrap_err().to_string().contains("Not a GitHub URI"));
208    }
209
210    #[test]
211    fn invalid_format_fails() {
212        let result = RemoteInfo::extract_github_repo_name("git@github.com:invalid");
213        assert!(result.is_err());
214        assert!(result
215            .unwrap_err()
216            .to_string()
217            .contains("Invalid GitHub repository format"));
218    }
219
220    // ── property tests ────────────────────────────────────────────
221
222    mod prop {
223        use super::*;
224        use proptest::prelude::*;
225
226        proptest! {
227            #[test]
228            fn ssh_url_extracts_repo(
229                owner in "[a-z]{3,10}",
230                repo in "[a-z]{3,10}",
231            ) {
232                let url = format!("git@github.com:{owner}/{repo}.git");
233                let result = RemoteInfo::extract_github_repo_name(&url).unwrap();
234                prop_assert_eq!(result, format!("{owner}/{repo}"));
235            }
236
237            #[test]
238            fn https_url_extracts_repo(
239                owner in "[a-z]{3,10}",
240                repo in "[a-z]{3,10}",
241            ) {
242                let url = format!("https://github.com/{owner}/{repo}.git");
243                let result = RemoteInfo::extract_github_repo_name(&url).unwrap();
244                prop_assert_eq!(result, format!("{owner}/{repo}"));
245            }
246
247            #[test]
248            fn non_github_url_errors(
249                host in "(gitlab|bitbucket|codeberg)",
250                path in "[a-z]{3,10}/[a-z]{3,10}",
251            ) {
252                let url = format!("git@{host}.com:{path}.git");
253                prop_assert!(RemoteInfo::extract_github_repo_name(&url).is_err());
254            }
255        }
256    }
257}