Skip to main content

subcog/git/
remote.rs

1//! Git remote operations.
2//!
3//! Provides git context detection for repository, branch, and remote information.
4
5use crate::{Error, Result};
6use git2::Repository;
7use std::path::Path;
8
9/// Manages git remote operations for context detection.
10pub struct RemoteManager {
11    /// Path to the repository.
12    repo_path: std::path::PathBuf,
13}
14
15impl RemoteManager {
16    /// Creates a new remote manager.
17    #[must_use]
18    pub fn new(repo_path: impl AsRef<Path>) -> Self {
19        Self {
20            repo_path: repo_path.as_ref().to_path_buf(),
21        }
22    }
23
24    /// Opens the git repository.
25    fn open_repo(&self) -> Result<Repository> {
26        Repository::open(&self.repo_path).map_err(|e| Error::OperationFailed {
27            operation: "open_repository".to_string(),
28            cause: e.to_string(),
29        })
30    }
31
32    /// Lists available remotes.
33    ///
34    /// # Errors
35    ///
36    /// Returns an error if listing fails.
37    pub fn list_remotes(&self) -> Result<Vec<String>> {
38        let repo = self.open_repo()?;
39
40        let remotes = repo.remotes().map_err(|e| Error::OperationFailed {
41            operation: "list_remotes".to_string(),
42            cause: e.to_string(),
43        })?;
44
45        Ok(remotes.iter().filter_map(|r| r.map(String::from)).collect())
46    }
47
48    /// Gets the URL for a remote.
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if the remote doesn't exist.
53    pub fn get_remote_url(&self, remote_name: &str) -> Result<Option<String>> {
54        let repo = self.open_repo()?;
55
56        match repo.find_remote(remote_name) {
57            Ok(remote) => Ok(remote.url().map(String::from)),
58            Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
59            Err(e) => Err(Error::OperationFailed {
60                operation: "get_remote_url".to_string(),
61                cause: e.to_string(),
62            }),
63        }
64    }
65
66    /// Checks if a remote exists.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if the check fails.
71    pub fn remote_exists(&self, remote_name: &str) -> Result<bool> {
72        self.get_remote_url(remote_name).map(|url| url.is_some())
73    }
74
75    /// Gets the default remote name (usually "origin").
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if no remotes exist.
80    pub fn default_remote(&self) -> Result<Option<String>> {
81        let remotes = self.list_remotes()?;
82
83        // Prefer "origin" if it exists
84        if remotes.contains(&"origin".to_string()) {
85            return Ok(Some("origin".to_string()));
86        }
87
88        // Otherwise return the first remote
89        Ok(remotes.into_iter().next())
90    }
91
92    /// Gets the current branch name.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the branch cannot be determined.
97    pub fn current_branch(&self) -> Result<Option<String>> {
98        let repo = self.open_repo()?;
99
100        let head = match repo.head() {
101            Ok(h) => h,
102            Err(e) if e.code() == git2::ErrorCode::UnbornBranch => return Ok(None),
103            Err(e) => {
104                return Err(Error::OperationFailed {
105                    operation: "get_head".to_string(),
106                    cause: e.to_string(),
107                });
108            },
109        };
110
111        if head.is_branch() {
112            Ok(head.shorthand().map(String::from))
113        } else {
114            Ok(None)
115        }
116    }
117
118    /// Gets the repository root path.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if the repository cannot be opened.
123    pub fn repo_root(&self) -> Result<std::path::PathBuf> {
124        let repo = self.open_repo()?;
125        repo.workdir()
126            .map(std::path::Path::to_path_buf)
127            .ok_or_else(|| Error::OperationFailed {
128                operation: "get_workdir".to_string(),
129                cause: "Repository has no working directory (bare repo)".to_string(),
130            })
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use git2::Signature;
138    use tempfile::TempDir;
139
140    fn create_test_repo() -> (TempDir, Repository) {
141        let dir = TempDir::new().unwrap();
142        let repo = Repository::init(dir.path()).unwrap();
143
144        // Create an initial commit in a separate scope so tree is dropped before returning
145        {
146            let sig = Signature::now("test", "test@test.com").unwrap();
147            let tree_id = repo.index().unwrap().write_tree().unwrap();
148            let tree = repo.find_tree(tree_id).unwrap();
149            repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
150                .unwrap();
151        }
152
153        (dir, repo)
154    }
155
156    #[test]
157    fn test_remote_manager_creation() {
158        let _manager = RemoteManager::new("/tmp/test");
159        // Just verifies creation works
160    }
161
162    #[test]
163    fn test_list_remotes_empty() {
164        let (dir, _repo) = create_test_repo();
165        let manager = RemoteManager::new(dir.path());
166
167        let remotes = manager.list_remotes().unwrap();
168        assert!(remotes.is_empty());
169    }
170
171    #[test]
172    fn test_list_remotes_with_origin() {
173        let (dir, repo) = create_test_repo();
174
175        // Add a remote
176        repo.remote("origin", "https://github.com/test/test.git")
177            .unwrap();
178
179        let manager = RemoteManager::new(dir.path());
180        let remotes = manager.list_remotes().unwrap();
181
182        assert_eq!(remotes.len(), 1);
183        assert_eq!(remotes[0], "origin");
184    }
185
186    #[test]
187    fn test_get_remote_url() {
188        let (dir, repo) = create_test_repo();
189        let test_url = "https://github.com/test/test.git";
190
191        repo.remote("origin", test_url).unwrap();
192
193        let manager = RemoteManager::new(dir.path());
194        let url = manager.get_remote_url("origin").unwrap();
195
196        assert_eq!(url, Some(test_url.to_string()));
197    }
198
199    #[test]
200    fn test_get_nonexistent_remote_url() {
201        let (dir, _repo) = create_test_repo();
202        let manager = RemoteManager::new(dir.path());
203
204        let url = manager.get_remote_url("nonexistent").unwrap();
205        assert!(url.is_none());
206    }
207
208    #[test]
209    fn test_remote_exists() {
210        let (dir, repo) = create_test_repo();
211        repo.remote("origin", "https://github.com/test/test.git")
212            .unwrap();
213
214        let manager = RemoteManager::new(dir.path());
215
216        assert!(manager.remote_exists("origin").unwrap());
217        assert!(!manager.remote_exists("nonexistent").unwrap());
218    }
219
220    #[test]
221    fn test_default_remote() {
222        let (dir, repo) = create_test_repo();
223
224        let manager = RemoteManager::new(dir.path());
225
226        // No remotes
227        assert!(manager.default_remote().unwrap().is_none());
228
229        // Add origin
230        repo.remote("origin", "https://github.com/test/test.git")
231            .unwrap();
232
233        assert_eq!(
234            manager.default_remote().unwrap(),
235            Some("origin".to_string())
236        );
237    }
238
239    #[test]
240    fn test_default_remote_prefers_origin() {
241        let (dir, repo) = create_test_repo();
242
243        // Add remotes in non-origin order
244        repo.remote("upstream", "https://github.com/upstream/test.git")
245            .unwrap();
246        repo.remote("origin", "https://github.com/test/test.git")
247            .unwrap();
248
249        let manager = RemoteManager::new(dir.path());
250
251        // Should prefer origin
252        assert_eq!(
253            manager.default_remote().unwrap(),
254            Some("origin".to_string())
255        );
256    }
257
258    #[test]
259    fn test_current_branch() {
260        let (dir, _repo) = create_test_repo();
261        let manager = RemoteManager::new(dir.path());
262
263        // Default branch after init is usually "master" or "main"
264        let branch = manager.current_branch().unwrap();
265        assert!(branch.is_some());
266    }
267
268    #[test]
269    fn test_repo_root() {
270        let (dir, _repo) = create_test_repo();
271        let manager = RemoteManager::new(dir.path());
272
273        let root = manager.repo_root().unwrap();
274        // Canonicalize both paths to handle symlinks (e.g., /var -> /private/var on macOS)
275        let expected = dir.path().canonicalize().unwrap();
276        let actual = root.canonicalize().unwrap();
277        assert_eq!(actual, expected);
278    }
279}