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        if let Some(branch_name) = Self::main_branch_from_remote_head(repo, remote_name) {
57            return Ok(branch_name);
58        }
59
60        // Try using GitHub CLI for GitHub repositories
61        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        // Fallback to checking common branch names, preferring origin remote
72        if let Some(branch_name) = Self::main_branch_from_common_names(repo, remote_name) {
73            return Ok(branch_name);
74        }
75
76        // If no common branch found, try to find any branch
77        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        // If still no branch found, return "unknown"
91        Ok("unknown".to_string())
92    }
93
94    /// Detects the main branch for a remote using only local refs — no network,
95    /// no `gh` subprocess.
96    ///
97    /// Returns `None` when neither the remote's symbolic HEAD nor a common
98    /// branch name (`main`/`master`/`develop`) resolves. Callers that treat the
99    /// result as a safety signal must stay conservative on `None`; unlike
100    /// [`Self::detect_main_branch`], this never falls back to an arbitrary
101    /// remote branch.
102    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    /// Returns the branch the remote's symbolic `HEAD` reference points at.
108    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        // Extract branch name from refs/remotes/origin/main
113        target
114            .strip_prefix(&format!("refs/remotes/{remote_name}/"))
115            .map(ToString::to_string)
116    }
117
118    /// Returns the first common branch name (`main`/`master`/`develop`) that
119    /// exists as a remote-tracking ref, preferring the origin remote.
120    fn main_branch_from_common_names(repo: &Repository, remote_name: &str) -> Option<String> {
121        let common_branches = ["main", "master", "develop"];
122
123        // First, check if this is the origin remote or if origin remote branches exist
124        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 non-origin remotes, first check if origin has these branches
133            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            // Then check the actual remote
141            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    /// Returns the default branch from GitHub using gh CLI.
153    ///
154    /// `repo_root` anchors the `gh` subprocess to the repository's working
155    /// directory. The `gh repo view <repo_name>` invocation already passes an
156    /// explicit repo argument (so it is CWD-inert), but the directory is pinned
157    /// for uniformity with the rest of the repo-anchored subprocess seams.
158    fn get_github_default_branch(uri: &str, repo_root: &std::path::Path) -> Result<String> {
159        use std::process::Command;
160
161        // Extract repository name from URI
162        let repo_name = Self::extract_github_repo_name(uri)?;
163
164        // Use gh CLI to get default branch
165        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    /// Extracts GitHub repository name from URI.
192    fn extract_github_repo_name(uri: &str) -> Result<String> {
193        // Handle both SSH and HTTPS GitHub URIs
194        let repo_name = if uri.starts_with("git@github.com:") {
195            // SSH format: git@github.com:owner/repo.git
196            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            // HTTPS format: https://github.com/owner/repo.git
201            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    // ── extract_github_repo_name ─────────────────────────────────────
223
224    #[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    // ── property tests ────────────────────────────────────────────
266
267    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    // ── detect_main_branch_local ─────────────────────────────────────
304
305    /// Creates a repo with one commit, anchored at `$CARGO_MANIFEST_DIR/tmp`,
306    /// and returns the commit id for wiring up remote-tracking refs.
307    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        // Unlike `detect_main_branch`, the local variant must not fall back to
350        // an arbitrary remote branch: `None` keeps safety consumers conservative.
351        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}