Skip to main content

gitgrip/git/
git2_backend.rs

1//! Git2 (libgit2) implementation of [`GitBackend`] / [`GitRepo`].
2//!
3//! Every method delegates to the existing free functions in `src/git/`,
4//! so behaviour is identical to calling them directly. This wrapper exists
5//! so new code can program against the trait, and tests can substitute a
6//! mock backend.
7
8use std::path::{Path, PathBuf};
9
10use git2::Repository;
11
12use super::backend::{GitBackend, GitRepo};
13use super::remote::SafePullResult;
14use super::status::RepoStatusInfo;
15use super::GitError;
16
17// ── Backend (factory) ──────────────────────────────────────────────────
18
19/// Default backend backed by libgit2 + `git` CLI.
20pub struct Git2Backend;
21
22impl GitBackend for Git2Backend {
23    fn open_repo(&self, path: &Path) -> Result<Box<dyn GitRepo>, GitError> {
24        let repo = super::open_repo(path)?;
25        Ok(Box::new(Git2Repo::new(repo)))
26    }
27
28    fn clone_repo(
29        &self,
30        url: &str,
31        path: &Path,
32        branch: Option<&str>,
33    ) -> Result<Box<dyn GitRepo>, GitError> {
34        let repo = super::clone_repo(url, path, branch)?;
35        Ok(Box::new(Git2Repo::new(repo)))
36    }
37
38    fn is_git_repo(&self, path: &Path) -> bool {
39        super::is_git_repo(path)
40    }
41}
42
43// ── Repo handle ────────────────────────────────────────────────────────
44
45/// Wraps a `git2::Repository` and delegates every operation to the
46/// existing free functions in `crate::git`.
47struct Git2Repo {
48    repo: Repository,
49    /// Cached working-directory path (avoids repeated lookups).
50    workdir: PathBuf,
51}
52
53impl Git2Repo {
54    fn new(repo: Repository) -> Self {
55        let workdir = super::get_workdir(&repo).to_path_buf();
56        Self { repo, workdir }
57    }
58}
59
60impl GitRepo for Git2Repo {
61    // ── identity ───────────────────────────────────────────────────────
62
63    fn workdir(&self) -> &Path {
64        &self.workdir
65    }
66
67    fn current_branch(&self) -> Result<String, GitError> {
68        super::get_current_branch(&self.repo)
69    }
70
71    fn head_commit_id(&self) -> Result<String, GitError> {
72        let head = self
73            .repo
74            .head()
75            .map_err(|e| GitError::Reference(e.to_string()))?;
76        let oid = head
77            .target()
78            .ok_or_else(|| GitError::Reference("HEAD has no target".to_string()))?;
79        Ok(oid.to_string())
80    }
81
82    // ── branch operations ──────────────────────────────────────────────
83
84    fn create_and_checkout_branch(&self, name: &str) -> Result<(), GitError> {
85        super::branch::create_and_checkout_branch(&self.repo, name)
86    }
87
88    fn checkout_branch(&self, name: &str) -> Result<(), GitError> {
89        super::branch::checkout_branch(&self.repo, name)
90    }
91
92    fn branch_exists(&self, name: &str) -> bool {
93        super::branch::branch_exists(&self.repo, name)
94    }
95
96    fn remote_branch_exists(&self, name: &str, remote: &str) -> bool {
97        super::branch::remote_branch_exists(&self.repo, name, remote)
98    }
99
100    fn delete_branch(&self, name: &str, force: bool) -> Result<(), GitError> {
101        super::branch::delete_local_branch(&self.repo, name, force)
102    }
103
104    fn list_local_branches(&self) -> Result<Vec<String>, GitError> {
105        super::branch::list_local_branches(&self.repo)
106    }
107
108    // ── remote operations ──────────────────────────────────────────────
109
110    fn fetch(&self, remote: &str) -> Result<(), GitError> {
111        super::remote::fetch_remote(&self.repo, remote)
112    }
113
114    fn pull(&self, remote: &str) -> Result<(), GitError> {
115        super::remote::pull_latest(&self.repo, remote)
116    }
117
118    fn push(&self, branch: &str, remote: &str, set_upstream: bool) -> Result<(), GitError> {
119        super::remote::push_branch(&self.repo, branch, remote, set_upstream)
120    }
121
122    fn get_remote_url(&self, remote: &str) -> Result<Option<String>, GitError> {
123        super::remote::get_remote_url(&self.repo, remote)
124    }
125
126    // ── working-tree queries ───────────────────────────────────────────
127
128    fn status(&self) -> Result<RepoStatusInfo, GitError> {
129        super::status::get_status_info(&self.repo)
130    }
131
132    fn reset_hard(&self, target: &str) -> Result<(), GitError> {
133        super::remote::reset_hard(&self.repo, target)
134    }
135
136    fn safe_pull(&self, default_branch: &str, remote: &str) -> Result<SafePullResult, GitError> {
137        super::remote::safe_pull_latest(&self.repo, default_branch, remote)
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::fs;
145    use std::process::Command;
146    use tempfile::TempDir;
147
148    fn setup_git_repo() -> TempDir {
149        let temp = TempDir::new().unwrap();
150        let dir = temp.path();
151
152        Command::new("git")
153            .args(["init", "-b", "main"])
154            .current_dir(dir)
155            .output()
156            .unwrap();
157        Command::new("git")
158            .args(["config", "user.email", "test@example.com"])
159            .current_dir(dir)
160            .output()
161            .unwrap();
162        Command::new("git")
163            .args(["config", "user.name", "Test User"])
164            .current_dir(dir)
165            .output()
166            .unwrap();
167        fs::write(dir.join("README.md"), "# Test").unwrap();
168        Command::new("git")
169            .args(["add", "README.md"])
170            .current_dir(dir)
171            .output()
172            .unwrap();
173        Command::new("git")
174            .args(["commit", "-m", "Initial commit"])
175            .current_dir(dir)
176            .output()
177            .unwrap();
178
179        temp
180    }
181
182    #[test]
183    fn test_backend_open_and_read_head() {
184        let temp = setup_git_repo();
185        let backend = Git2Backend;
186
187        let repo = backend.open_repo(temp.path()).unwrap();
188        assert_eq!(repo.current_branch().unwrap(), "main");
189        assert!(!repo.head_commit_id().unwrap().is_empty());
190        // Compare canonical paths to handle macOS /var -> /private/var symlink
191        assert_eq!(
192            repo.workdir().canonicalize().unwrap(),
193            temp.path().canonicalize().unwrap()
194        );
195    }
196
197    #[test]
198    fn test_backend_is_git_repo() {
199        let temp = setup_git_repo();
200        let backend = Git2Backend;
201
202        assert!(backend.is_git_repo(temp.path()));
203
204        let non_repo = TempDir::new().unwrap();
205        assert!(!backend.is_git_repo(non_repo.path()));
206    }
207
208    #[test]
209    fn test_backend_branch_operations() {
210        let temp = setup_git_repo();
211        let backend = Git2Backend;
212        let repo = backend.open_repo(temp.path()).unwrap();
213
214        assert!(!repo.branch_exists("feature"));
215
216        repo.create_and_checkout_branch("feature").unwrap();
217        assert!(repo.branch_exists("feature"));
218        assert_eq!(repo.current_branch().unwrap(), "feature");
219
220        let branches = repo.list_local_branches().unwrap();
221        assert!(branches.contains(&"main".to_string()));
222        assert!(branches.contains(&"feature".to_string()));
223
224        repo.checkout_branch("main").unwrap();
225        assert_eq!(repo.current_branch().unwrap(), "main");
226
227        repo.delete_branch("feature", false).unwrap();
228        assert!(!repo.branch_exists("feature"));
229    }
230
231    #[test]
232    fn test_backend_status() {
233        let temp = setup_git_repo();
234        let backend = Git2Backend;
235        let repo = backend.open_repo(temp.path()).unwrap();
236
237        let status = repo.status().unwrap();
238        assert!(status.is_clean);
239
240        // Create an untracked file
241        fs::write(temp.path().join("new.txt"), "hello").unwrap();
242        let status = repo.status().unwrap();
243        assert!(!status.is_clean);
244        assert_eq!(status.untracked.len(), 1);
245    }
246
247    #[test]
248    fn test_backend_open_nonexistent_fails() {
249        let backend = Git2Backend;
250        assert!(backend.open_repo(Path::new("/nonexistent")).is_err());
251    }
252}