1use crate::errors::GitError;
7use std::path::Path;
8
9#[derive(Debug, Clone, Default)]
11pub struct CloneOptions {
12 pub depth: u32,
14 pub branch: Option<String>,
16 pub recurse_submodules: bool,
18}
19
20impl CloneOptions {
21 pub fn new() -> Self {
23 Self::default()
24 }
25
26 pub fn with_depth(mut self, depth: u32) -> Self {
28 self.depth = depth;
29 self
30 }
31
32 pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
34 self.branch = Some(branch.into());
35 self
36 }
37
38 pub fn with_submodules(mut self) -> Self {
40 self.recurse_submodules = true;
41 self
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct RepoStatus {
48 pub branch: String,
50 pub is_uncommitted: bool,
52 pub ahead: u32,
54 pub behind: u32,
56 pub has_untracked: bool,
58 pub staged_count: usize,
60 pub unstaged_count: usize,
62 pub untracked_count: usize,
64}
65
66impl RepoStatus {
67 pub fn is_clean_and_synced(&self) -> bool {
69 !self.is_uncommitted && !self.has_untracked && self.ahead == 0 && self.behind == 0
70 }
71
72 pub fn can_fast_forward(&self) -> bool {
74 !self.is_uncommitted && self.ahead == 0 && self.behind > 0
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct FetchResult {
81 pub updated: bool,
83 pub new_commits: Option<u32>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct PullResult {
90 pub success: bool,
92 pub updated: bool,
94 pub fast_forward: bool,
96 pub error: Option<String>,
98}
99
100pub trait GitOperations: Send + Sync {
104 fn clone_repo(&self, url: &str, target: &Path, options: &CloneOptions) -> Result<(), GitError>;
111
112 fn fetch(&self, repo_path: &Path) -> Result<FetchResult, GitError>;
117
118 fn pull(&self, repo_path: &Path) -> Result<PullResult, GitError>;
123
124 fn status(&self, repo_path: &Path) -> Result<RepoStatus, GitError>;
129
130 fn is_repo(&self, path: &Path) -> bool;
135
136 fn current_branch(&self, repo_path: &Path) -> Result<String, GitError>;
141
142 fn remote_url(&self, repo_path: &Path, remote: &str) -> Result<String, GitError>;
148
149 fn recent_commits(&self, repo_path: &Path, limit: usize) -> Result<Vec<String>, GitError>;
155}
156
157#[cfg(test)]
159pub mod mock {
160 use super::*;
161 use std::collections::HashMap;
162 use std::sync::{Arc, Mutex};
163
164 #[derive(Debug, Clone, Default)]
166 pub struct MockCallLog {
167 pub clones: Vec<(String, String, CloneOptions)>, pub fetches: Vec<String>, pub pulls: Vec<String>, pub status_checks: Vec<String>, }
172
173 #[derive(Debug, Clone)]
175 pub struct MockConfig {
176 pub clone_succeeds: bool,
178 pub fetch_succeeds: bool,
180 pub pull_succeeds: bool,
182 pub fetch_has_updates: bool,
184 pub default_status: RepoStatus,
186 pub path_statuses: HashMap<String, RepoStatus>,
188 pub valid_repos: Vec<String>,
190 pub error_message: Option<String>,
192 }
193
194 impl Default for MockConfig {
195 fn default() -> Self {
196 Self {
197 clone_succeeds: true,
198 fetch_succeeds: true,
199 pull_succeeds: true,
200 fetch_has_updates: false,
201 default_status: RepoStatus {
202 branch: "main".to_string(),
203 is_uncommitted: false,
204 ahead: 0,
205 behind: 0,
206 has_untracked: false,
207 staged_count: 0,
208 unstaged_count: 0,
209 untracked_count: 0,
210 },
211 path_statuses: HashMap::new(),
212 valid_repos: Vec::new(),
213 error_message: None,
214 }
215 }
216 }
217
218 pub struct MockGit {
220 config: MockConfig,
221 log: Arc<Mutex<MockCallLog>>,
222 }
223
224 impl MockGit {
225 pub fn new() -> Self {
227 Self {
228 config: MockConfig::default(),
229 log: Arc::new(Mutex::new(MockCallLog::default())),
230 }
231 }
232
233 pub fn with_config(config: MockConfig) -> Self {
235 Self {
236 config,
237 log: Arc::new(Mutex::new(MockCallLog::default())),
238 }
239 }
240
241 pub fn call_log(&self) -> MockCallLog {
243 self.log.lock().unwrap().clone()
244 }
245
246 pub fn add_repo(&mut self, path: impl Into<String>) {
248 self.config.valid_repos.push(path.into());
249 }
250
251 pub fn set_status(&mut self, path: impl Into<String>, status: RepoStatus) {
253 self.config.path_statuses.insert(path.into(), status);
254 }
255
256 pub fn fail_clones(&mut self, message: Option<String>) {
258 self.config.clone_succeeds = false;
259 self.config.error_message = message;
260 }
261
262 pub fn fail_fetches(&mut self, message: Option<String>) {
264 self.config.fetch_succeeds = false;
265 self.config.error_message = message;
266 }
267
268 pub fn fail_pulls(&mut self, message: Option<String>) {
270 self.config.pull_succeeds = false;
271 self.config.error_message = message;
272 }
273 }
274
275 impl Default for MockGit {
276 fn default() -> Self {
277 Self::new()
278 }
279 }
280
281 impl GitOperations for MockGit {
282 fn clone_repo(
283 &self,
284 url: &str,
285 target: &Path,
286 options: &CloneOptions,
287 ) -> Result<(), GitError> {
288 let mut log = self.log.lock().unwrap();
289 log.clones.push((
290 url.to_string(),
291 target.to_string_lossy().to_string(),
292 options.clone(),
293 ));
294
295 if self.config.clone_succeeds {
296 Ok(())
297 } else {
298 Err(GitError::clone_failed(
299 url,
300 self.config
301 .error_message
302 .as_deref()
303 .unwrap_or("mock clone failure"),
304 ))
305 }
306 }
307
308 fn fetch(&self, repo_path: &Path) -> Result<FetchResult, GitError> {
309 let mut log = self.log.lock().unwrap();
310 log.fetches.push(repo_path.to_string_lossy().to_string());
311
312 if self.config.fetch_succeeds {
313 Ok(FetchResult {
314 updated: self.config.fetch_has_updates,
315 new_commits: if self.config.fetch_has_updates {
316 Some(3)
317 } else {
318 Some(0)
319 },
320 })
321 } else {
322 Err(GitError::fetch_failed(
323 repo_path,
324 self.config
325 .error_message
326 .as_deref()
327 .unwrap_or("mock fetch failure"),
328 ))
329 }
330 }
331
332 fn pull(&self, repo_path: &Path) -> Result<PullResult, GitError> {
333 let mut log = self.log.lock().unwrap();
334 log.pulls.push(repo_path.to_string_lossy().to_string());
335
336 if self.config.pull_succeeds {
337 Ok(PullResult {
338 success: true,
339 updated: true,
340 fast_forward: true,
341 error: None,
342 })
343 } else {
344 Err(GitError::pull_failed(
345 repo_path,
346 self.config
347 .error_message
348 .as_deref()
349 .unwrap_or("mock pull failure"),
350 ))
351 }
352 }
353
354 fn status(&self, repo_path: &Path) -> Result<RepoStatus, GitError> {
355 let mut log = self.log.lock().unwrap();
356 let path_str = repo_path.to_string_lossy().to_string();
357 log.status_checks.push(path_str.clone());
358
359 if let Some(status) = self.config.path_statuses.get(&path_str) {
360 Ok(status.clone())
361 } else {
362 Ok(self.config.default_status.clone())
363 }
364 }
365
366 fn is_repo(&self, path: &Path) -> bool {
367 let path_str = path.to_string_lossy().to_string();
368 self.config.valid_repos.contains(&path_str)
369 }
370
371 fn current_branch(&self, repo_path: &Path) -> Result<String, GitError> {
372 let path_str = repo_path.to_string_lossy().to_string();
373 if let Some(status) = self.config.path_statuses.get(&path_str) {
374 Ok(status.branch.clone())
375 } else {
376 Ok(self.config.default_status.branch.clone())
377 }
378 }
379
380 fn remote_url(&self, _repo_path: &Path, _remote: &str) -> Result<String, GitError> {
381 Ok("git@github.com:example/repo.git".to_string())
382 }
383
384 fn recent_commits(
385 &self,
386 _repo_path: &Path,
387 _limit: usize,
388 ) -> Result<Vec<String>, GitError> {
389 Ok(Vec::new())
390 }
391 }
392}
393
394#[cfg(test)]
395#[path = "traits_tests.rs"]
396mod tests;