Skip to main content

git_same/git/
traits.rs

1//! Git operations trait definitions.
2//!
3//! This module defines the trait abstractions for git operations,
4//! allowing for both real and mock implementations for testing.
5
6use crate::errors::GitError;
7use std::path::Path;
8
9/// Options for cloning a repository.
10#[derive(Debug, Clone, Default)]
11pub struct CloneOptions {
12    /// Clone depth (0 = full clone)
13    pub depth: u32,
14    /// Specific branch to clone
15    pub branch: Option<String>,
16    /// Whether to recurse into submodules
17    pub recurse_submodules: bool,
18}
19
20impl CloneOptions {
21    /// Creates new clone options with defaults.
22    pub fn new() -> Self {
23        Self::default()
24    }
25
26    /// Sets the clone depth.
27    pub fn with_depth(mut self, depth: u32) -> Self {
28        self.depth = depth;
29        self
30    }
31
32    /// Sets the branch to clone.
33    pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
34        self.branch = Some(branch.into());
35        self
36    }
37
38    /// Enables recursive submodule cloning.
39    pub fn with_submodules(mut self) -> Self {
40        self.recurse_submodules = true;
41        self
42    }
43}
44
45/// Status of a local repository.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct RepoStatus {
48    /// Current branch name
49    pub branch: String,
50    /// Whether the working tree has uncommitted changes
51    pub is_uncommitted: bool,
52    /// Number of commits ahead of upstream
53    pub ahead: u32,
54    /// Number of commits behind upstream
55    pub behind: u32,
56    /// Whether there are untracked files
57    pub has_untracked: bool,
58    /// Number of staged (index) changes
59    pub staged_count: usize,
60    /// Number of unstaged (working tree) changes
61    pub unstaged_count: usize,
62    /// Number of untracked files
63    pub untracked_count: usize,
64}
65
66impl RepoStatus {
67    /// Returns true if the repo is clean and in sync with upstream.
68    pub fn is_clean_and_synced(&self) -> bool {
69        !self.is_uncommitted && !self.has_untracked && self.ahead == 0 && self.behind == 0
70    }
71
72    /// Returns true if it's safe to do a fast-forward pull.
73    pub fn can_fast_forward(&self) -> bool {
74        !self.is_uncommitted && self.ahead == 0 && self.behind > 0
75    }
76}
77
78/// Result of a fetch operation.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct FetchResult {
81    /// Whether any new commits were fetched
82    pub updated: bool,
83    /// Number of new commits (if available)
84    pub new_commits: Option<u32>,
85}
86
87/// Result of a pull operation.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct PullResult {
90    /// Whether the pull was successful
91    pub success: bool,
92    /// Whether the pull applied updates to the local branch
93    pub updated: bool,
94    /// Whether this was a fast-forward
95    pub fast_forward: bool,
96    /// Error message if not successful
97    pub error: Option<String>,
98}
99
100/// Trait for git operations.
101///
102/// This trait abstracts git commands to allow for testing with mocks.
103pub trait GitOperations: Send + Sync {
104    /// Clones a repository to the target path.
105    ///
106    /// # Arguments
107    /// * `url` - The clone URL (SSH or HTTPS)
108    /// * `target` - Target directory path
109    /// * `options` - Clone options (depth, branch, submodules)
110    fn clone_repo(&self, url: &str, target: &Path, options: &CloneOptions) -> Result<(), GitError>;
111
112    /// Fetches updates from the remote.
113    ///
114    /// # Arguments
115    /// * `repo_path` - Path to the local repository
116    fn fetch(&self, repo_path: &Path) -> Result<FetchResult, GitError>;
117
118    /// Pulls updates from the remote.
119    ///
120    /// # Arguments
121    /// * `repo_path` - Path to the local repository
122    fn pull(&self, repo_path: &Path) -> Result<PullResult, GitError>;
123
124    /// Gets the status of a local repository.
125    ///
126    /// # Arguments
127    /// * `repo_path` - Path to the local repository
128    fn status(&self, repo_path: &Path) -> Result<RepoStatus, GitError>;
129
130    /// Checks if a directory is a git repository.
131    ///
132    /// # Arguments
133    /// * `path` - Path to check
134    fn is_repo(&self, path: &Path) -> bool;
135
136    /// Gets the current branch name.
137    ///
138    /// # Arguments
139    /// * `repo_path` - Path to the local repository
140    fn current_branch(&self, repo_path: &Path) -> Result<String, GitError>;
141
142    /// Gets the remote URL for a repository.
143    ///
144    /// # Arguments
145    /// * `repo_path` - Path to the local repository
146    /// * `remote` - Remote name (default: "origin")
147    fn remote_url(&self, repo_path: &Path, remote: &str) -> Result<String, GitError>;
148
149    /// Gets recent commits as one-line summaries.
150    ///
151    /// # Arguments
152    /// * `repo_path` - Path to the local repository
153    /// * `limit` - Maximum number of commits to return
154    fn recent_commits(&self, repo_path: &Path, limit: usize) -> Result<Vec<String>, GitError>;
155}
156
157/// A mock implementation of GitOperations for testing.
158#[cfg(test)]
159pub mod mock {
160    use super::*;
161    use std::collections::HashMap;
162    use std::sync::{Arc, Mutex};
163
164    /// Records of operations performed.
165    #[derive(Debug, Clone, Default)]
166    pub struct MockCallLog {
167        pub clones: Vec<(String, String, CloneOptions)>, // (url, path, options)
168        pub fetches: Vec<String>,                        // paths
169        pub pulls: Vec<String>,                          // paths
170        pub status_checks: Vec<String>,                  // paths
171    }
172
173    /// Configuration for mock responses.
174    #[derive(Debug, Clone)]
175    pub struct MockConfig {
176        /// Whether clone operations should succeed
177        pub clone_succeeds: bool,
178        /// Whether fetch operations should succeed
179        pub fetch_succeeds: bool,
180        /// Whether pull operations should succeed
181        pub pull_succeeds: bool,
182        /// Whether fetch reports updates
183        pub fetch_has_updates: bool,
184        /// Default status to return
185        pub default_status: RepoStatus,
186        /// Custom statuses per path
187        pub path_statuses: HashMap<String, RepoStatus>,
188        /// Paths that are valid repos
189        pub valid_repos: Vec<String>,
190        /// Custom error message for failures
191        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    /// Mock git operations for testing.
219    pub struct MockGit {
220        config: MockConfig,
221        log: Arc<Mutex<MockCallLog>>,
222    }
223
224    impl MockGit {
225        /// Creates a new mock with default configuration.
226        pub fn new() -> Self {
227            Self {
228                config: MockConfig::default(),
229                log: Arc::new(Mutex::new(MockCallLog::default())),
230            }
231        }
232
233        /// Creates a new mock with custom configuration.
234        pub fn with_config(config: MockConfig) -> Self {
235            Self {
236                config,
237                log: Arc::new(Mutex::new(MockCallLog::default())),
238            }
239        }
240
241        /// Gets the call log.
242        pub fn call_log(&self) -> MockCallLog {
243            self.log.lock().unwrap().clone()
244        }
245
246        /// Marks a path as a valid repo.
247        pub fn add_repo(&mut self, path: impl Into<String>) {
248            self.config.valid_repos.push(path.into());
249        }
250
251        /// Sets a custom status for a path.
252        pub fn set_status(&mut self, path: impl Into<String>, status: RepoStatus) {
253            self.config.path_statuses.insert(path.into(), status);
254        }
255
256        /// Configures clone to fail.
257        pub fn fail_clones(&mut self, message: Option<String>) {
258            self.config.clone_succeeds = false;
259            self.config.error_message = message;
260        }
261
262        /// Configures fetch to fail.
263        pub fn fail_fetches(&mut self, message: Option<String>) {
264            self.config.fetch_succeeds = false;
265            self.config.error_message = message;
266        }
267
268        /// Configures pull to fail.
269        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;