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