Skip to main content

changeset_operations/providers/
git.rs

1use std::path::{Path, PathBuf};
2use std::sync::Mutex;
3
4use changeset_git::{CommitInfo, FileChange, Repository, TagInfo};
5
6use crate::traits::{
7    GitCommitProvider, GitDiffProvider, GitStagingProvider, GitStatusProvider, GitTagProvider,
8};
9
10pub struct Git2Provider {
11    project_root: PathBuf,
12    repo: Mutex<Option<Repository>>,
13}
14
15impl Git2Provider {
16    /// # Errors
17    ///
18    /// Fails if the project root path cannot be canonicalized.
19    pub fn new(project_root: &Path) -> crate::Result<Self> {
20        let canonical = project_root.canonicalize().map_err(|source| {
21            crate::OperationError::ProjectRootCanonicalize {
22                path: project_root.to_path_buf(),
23                source,
24            }
25        })?;
26        Ok(Self {
27            project_root: canonical,
28            repo: Mutex::new(None),
29        })
30    }
31
32    fn with_repo<T>(
33        &self,
34        project_root: &Path,
35        f: impl FnOnce(&Repository) -> changeset_git::Result<T>,
36    ) -> crate::Result<T> {
37        let canonical = project_root.canonicalize().map_err(|source| {
38            crate::OperationError::ProjectRootCanonicalize {
39                path: project_root.to_path_buf(),
40                source,
41            }
42        })?;
43        if canonical != self.project_root {
44            return Err(crate::OperationError::ProjectRootMismatch {
45                expected: self.project_root.clone(),
46                actual: canonical,
47            });
48        }
49        let mut guard = self
50            .repo
51            .lock()
52            .expect("git repository mutex should not be poisoned");
53        if guard.is_none() {
54            *guard = Some(Repository::open(&self.project_root)?);
55        }
56        let repo = guard
57            .as_ref()
58            .expect("repository should be initialized after open");
59        Ok(f(repo)?)
60    }
61}
62
63impl GitDiffProvider for Git2Provider {
64    fn changed_files(
65        &self,
66        project_root: &Path,
67        base: &str,
68        head: &str,
69    ) -> crate::Result<Vec<FileChange>> {
70        self.with_repo(project_root, |repo| repo.changed_files(Some(base), head))
71    }
72}
73
74impl GitStatusProvider for Git2Provider {
75    fn is_working_tree_clean(&self, project_root: &Path) -> crate::Result<bool> {
76        self.with_repo(project_root, Repository::is_working_tree_clean)
77    }
78
79    fn current_branch(&self, project_root: &Path) -> crate::Result<String> {
80        self.with_repo(project_root, Repository::current_branch)
81    }
82
83    fn remote_url(&self, project_root: &Path) -> crate::Result<Option<String>> {
84        self.with_repo(project_root, Repository::remote_url)
85    }
86}
87
88impl GitStagingProvider for Git2Provider {
89    fn stage_files(&self, project_root: &Path, paths: &[&Path]) -> crate::Result<()> {
90        self.with_repo(project_root, |repo| repo.stage_files(paths))
91    }
92
93    fn delete_files(&self, project_root: &Path, paths: &[&Path]) -> crate::Result<()> {
94        self.with_repo(project_root, |repo| repo.delete_files(paths))
95    }
96}
97
98impl GitCommitProvider for Git2Provider {
99    fn commit(&self, project_root: &Path, message: &str) -> crate::Result<CommitInfo> {
100        self.with_repo(project_root, |repo| repo.commit(message))
101    }
102
103    fn reset_to_parent(&self, project_root: &Path) -> crate::Result<()> {
104        self.with_repo(project_root, Repository::reset_to_parent)
105    }
106}
107
108impl GitTagProvider for Git2Provider {
109    fn create_tag(
110        &self,
111        project_root: &Path,
112        tag_name: &str,
113        message: &str,
114    ) -> crate::Result<TagInfo> {
115        self.with_repo(project_root, |repo| repo.create_tag(tag_name, message))
116    }
117
118    fn delete_tag(&self, project_root: &Path, tag_name: &str) -> crate::Result<bool> {
119        self.with_repo(project_root, |repo| repo.delete_tag(tag_name))
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::OperationError;
127
128    fn create_temp_git_repo() -> (tempfile::TempDir, PathBuf) {
129        let dir = tempfile::TempDir::new().expect("create temp dir");
130        let repo = git2::Repository::init(dir.path()).expect("init git repo");
131
132        let sig = git2::Signature::now("Test", "test@test.com").expect("create signature");
133        let tree_id = repo
134            .index()
135            .expect("get index")
136            .write_tree()
137            .expect("write tree");
138        let tree = repo.find_tree(tree_id).expect("find tree");
139        repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
140            .expect("create initial commit");
141
142        let canonical = dir.path().canonicalize().expect("canonicalize temp dir");
143        (dir, canonical)
144    }
145
146    #[test]
147    fn opens_repository_successfully() {
148        let (_dir, canonical) = create_temp_git_repo();
149
150        let provider = Git2Provider::new(&canonical).expect("should create provider");
151
152        let result: crate::Result<bool> = provider.with_repo(&canonical, |_repo| Ok(true));
153
154        assert!(result.is_ok());
155        assert!(result.expect("should succeed"));
156    }
157
158    #[test]
159    fn returns_mismatch_for_different_paths() {
160        let (_dir1, canonical1) = create_temp_git_repo();
161        let (_dir2, canonical2) = create_temp_git_repo();
162
163        let provider = Git2Provider::new(&canonical1).expect("should create provider");
164
165        let result: crate::Result<bool> = provider.with_repo(&canonical2, |_repo| Ok(true));
166
167        assert!(result.is_err());
168        assert!(matches!(
169            result.expect_err("should fail"),
170            OperationError::ProjectRootMismatch { .. }
171        ));
172    }
173
174    #[test]
175    fn returns_canonicalize_error_for_nonexistent_path() {
176        let result = Git2Provider::new(Path::new("/nonexistent/path/to/project"));
177
178        assert!(result.is_err());
179        let err = result.err().expect("should be an error");
180        assert!(matches!(
181            err,
182            OperationError::ProjectRootCanonicalize { .. }
183        ));
184    }
185
186    #[test]
187    fn reuses_cached_repository() {
188        let (_dir, canonical) = create_temp_git_repo();
189
190        let provider = Git2Provider::new(&canonical).expect("should create provider");
191
192        let result1: crate::Result<bool> = provider.with_repo(&canonical, |_repo| Ok(true));
193        assert!(result1.is_ok());
194
195        assert!(
196            provider.repo.lock().expect("lock").is_some(),
197            "repository should be cached after first call"
198        );
199
200        let result2: crate::Result<bool> = provider.with_repo(&canonical, |_repo| Ok(true));
201        assert!(result2.is_ok());
202    }
203}