changeset_operations/providers/
git.rs1use 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 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}