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        // Anchor the `gh` subprocess fallback to the repository's working
25        // directory (RULE-1: git2 ops keep `&Repository`; the subprocess takes
26        // `repo_root`). Derived from the git2 repo itself, so no external caller
27        // signature changes.
28        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    /// Detects the main branch for a remote.
47    ///
48    /// `repo_root` anchors the `gh` subprocess fallback to the repository's
49    /// working directory rather than the process current working directory.
50    fn detect_main_branch(
51        repo: &Repository,
52        remote_name: &str,
53        repo_root: &std::path::Path,
54    ) -> Result<String> {
55        // First try to get the remote HEAD reference
56        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                // Extract branch name from refs/remotes/origin/main
60                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        // Try using GitHub CLI for GitHub repositories
69        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        // Fallback to checking common branch names, preferring origin remote
80        let common_branches = ["main", "master", "develop"];
81
82        // First, check if this is the origin remote or if origin remote branches exist
83        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 non-origin remotes, first check if origin has these branches
92            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            // Then check the actual remote
100            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        // If no common branch found, try to find any branch
109        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        // If still no branch found, return "unknown"
123        Ok("unknown".to_string())
124    }
125
126    /// Returns the default branch from GitHub using gh CLI.
127    ///
128    /// `repo_root` anchors the `gh` subprocess to the repository's working
129    /// directory. The `gh repo view <repo_name>` invocation already passes an
130    /// explicit repo argument (so it is CWD-inert), but the directory is pinned
131    /// for uniformity with the rest of the repo-anchored subprocess seams.
132    fn get_github_default_branch(uri: &str, repo_root: &std::path::Path) -> Result<String> {
133        use std::process::Command;
134
135        // Extract repository name from URI
136        let repo_name = Self::extract_github_repo_name(uri)?;
137
138        // Use gh CLI to get default branch
139        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    /// Extracts GitHub repository name from URI.
166    fn extract_github_repo_name(uri: &str) -> Result<String> {
167        // Handle both SSH and HTTPS GitHub URIs
168        let repo_name = if uri.starts_with("git@github.com:") {
169            // SSH format: git@github.com:owner/repo.git
170            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            // HTTPS format: https://github.com/owner/repo.git
175            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    // ── extract_github_repo_name ─────────────────────────────────────
197
198    #[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    // ── property tests ────────────────────────────────────────────
240
241    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}