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};
9
10pub struct Git2Provider {
11 project_root: PathBuf,
12 repo: Mutex<Option<Repository>>,
13}
14
15impl Git2Provider {
16 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}