rustic_git/
repository.rs

1use std::path::Path;
2use std::path::PathBuf;
3use std::sync::OnceLock;
4
5use crate::error::{GitError, Result};
6use crate::utils::{git, git_raw};
7
8static GIT_CHECKED: OnceLock<Result<()>> = OnceLock::new();
9
10#[derive(Debug)]
11pub struct Repository {
12    repo_path: PathBuf,
13}
14
15impl Repository {
16    /// Ensure that Git is available in the system PATH.
17    ///
18    /// This function checks if the `git` command is available in the system PATH.
19    /// The result is cached, so subsequent calls are very fast.
20    /// If Git is not found, it returns a `GitError::CommandFailed` with an appropriate error message.
21    ///
22    /// # Returns
23    ///
24    /// A `Result` containing either `Ok(())` if Git is available or a `GitError`.
25    pub fn ensure_git() -> Result<()> {
26        GIT_CHECKED
27            .get_or_init(|| {
28                git_raw(&["--version"], None)
29                    .map_err(|_| GitError::CommandFailed("Git not found in PATH".to_string()))
30                    .map(|_| ())
31            })
32            .clone()
33    }
34
35    /// Open an existing Git repository at the specified path.
36    ///
37    /// # Arguments
38    ///
39    /// * `path` - The path to an existing Git repository.
40    ///
41    /// # Returns
42    ///
43    /// A `Result` containing either the opened `Repository` instance or a `GitError`.
44    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
45        Self::ensure_git()?;
46
47        let path_ref = path.as_ref();
48
49        // Check if the path exists
50        if !path_ref.exists() {
51            return Err(GitError::CommandFailed(format!(
52                "Path does not exist: {}",
53                path_ref.display()
54            )));
55        }
56
57        // Check if it's a valid git repository by running git status
58        let _stdout = git(&["status", "--porcelain"], Some(path_ref))
59            .map_err(|_| GitError::CommandFailed(format!(
60                "Not a git repository: {}",
61                path_ref.display()
62            )))?;
63
64        Ok(Self {
65            repo_path: path_ref.to_path_buf(),
66        })
67    }
68
69    /// Initialize a new Git repository at the specified path.
70    ///
71    /// # Arguments
72    ///
73    /// * `path` - The path where the repository should be initialized.
74    /// * `bare` - Whether the repository should be bare or not.
75    ///
76    /// # Returns
77    ///
78    /// A `Result` containing either the initialized `Repository` instance or a `GitError`.
79    pub fn init<P: AsRef<Path>>(path: P, bare: bool) -> Result<Self> {
80        Self::ensure_git()?;
81
82        let mut args = vec!["init"];
83        if bare {
84            args.push("--bare");
85        }
86        args.push(path.as_ref().to_str().unwrap_or(""));
87
88        let _stdout = git(&args, None)?;
89
90        Ok(Self {
91            repo_path: path.as_ref().to_path_buf(),
92        })
93    }
94
95    pub fn repo_path(&self) -> &Path {
96        &self.repo_path
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use std::fs;
104    use std::path::Path;
105
106    #[test]
107    fn test_git_init_creates_repository() {
108        let test_path = "/tmp/test_repo";
109
110        // Clean up if exists
111        if Path::new(test_path).exists() {
112            fs::remove_dir_all(test_path).unwrap();
113        }
114
115        // Initialize repository
116        let repo = Repository::init(test_path, false).unwrap();
117
118        // Check that .git directory was created
119        assert!(Path::new(&format!("{}/.git", test_path)).exists());
120        assert_eq!(repo.repo_path(), Path::new(test_path));
121
122        // Clean up
123        fs::remove_dir_all(test_path).unwrap();
124    }
125
126    #[test]
127    fn test_git_init_bare_repository() {
128        let test_path = "/tmp/test_bare_repo";
129
130        // Clean up if exists
131        if Path::new(test_path).exists() {
132            fs::remove_dir_all(test_path).unwrap();
133        }
134
135        // Initialize bare repository
136        let repo = Repository::init(test_path, true).unwrap();
137
138        // Check that bare repo files were created (no .git subdirectory)
139        assert!(Path::new(&format!("{}/HEAD", test_path)).exists());
140        assert!(Path::new(&format!("{}/objects", test_path)).exists());
141        assert!(!Path::new(&format!("{}/.git", test_path)).exists());
142        assert_eq!(repo.repo_path(), Path::new(test_path));
143
144        // Clean up
145        fs::remove_dir_all(test_path).unwrap();
146    }
147
148    #[test]
149    fn test_open_existing_repository() {
150        let test_path = "/tmp/test_open_repo";
151
152        // Clean up if exists
153        if Path::new(test_path).exists() {
154            fs::remove_dir_all(test_path).unwrap();
155        }
156
157        // First create a repository
158        let _created_repo = Repository::init(test_path, false).unwrap();
159
160        // Now open the existing repository
161        let opened_repo = Repository::open(test_path).unwrap();
162        assert_eq!(opened_repo.repo_path(), Path::new(test_path));
163
164        // Clean up
165        fs::remove_dir_all(test_path).unwrap();
166    }
167
168    #[test]
169    fn test_open_nonexistent_path() {
170        let test_path = "/tmp/nonexistent_repo_path";
171
172        // Ensure path doesn't exist
173        if Path::new(test_path).exists() {
174            fs::remove_dir_all(test_path).unwrap();
175        }
176
177        // Try to open non-existent repository
178        let result = Repository::open(test_path);
179        assert!(result.is_err());
180
181        if let Err(GitError::CommandFailed(msg)) = result {
182            assert!(msg.contains("Path does not exist"));
183        } else {
184            panic!("Expected CommandFailed error");
185        }
186    }
187
188    #[test]
189    fn test_open_non_git_directory() {
190        let test_path = "/tmp/not_a_git_repo";
191
192        // Clean up if exists and create a regular directory
193        if Path::new(test_path).exists() {
194            fs::remove_dir_all(test_path).unwrap();
195        }
196        fs::create_dir(test_path).unwrap();
197
198        // Try to open directory that's not a git repository
199        let result = Repository::open(test_path);
200        assert!(result.is_err());
201
202        if let Err(GitError::CommandFailed(msg)) = result {
203            assert!(msg.contains("Not a git repository"));
204        } else {
205            panic!("Expected CommandFailed error");
206        }
207
208        // Clean up
209        fs::remove_dir_all(test_path).unwrap();
210    }
211
212    #[test]
213    fn test_repo_path_method() {
214        let test_path = "/tmp/test_repo_path";
215
216        // Clean up if exists
217        if Path::new(test_path).exists() {
218            fs::remove_dir_all(test_path).unwrap();
219        }
220
221        // Initialize repository
222        let repo = Repository::init(test_path, false).unwrap();
223
224        // Test repo_path method
225        assert_eq!(repo.repo_path(), Path::new(test_path));
226
227        // Clean up
228        fs::remove_dir_all(test_path).unwrap();
229    }
230
231    #[test]
232    fn test_repo_path_method_after_open() {
233        let test_path = "/tmp/test_repo_path_open";
234
235        // Clean up if exists
236        if Path::new(test_path).exists() {
237            fs::remove_dir_all(test_path).unwrap();
238        }
239
240        // Initialize and then open repository
241        let _created_repo = Repository::init(test_path, false).unwrap();
242        let opened_repo = Repository::open(test_path).unwrap();
243
244        // Test repo_path method on opened repository
245        assert_eq!(opened_repo.repo_path(), Path::new(test_path));
246
247        // Clean up
248        fs::remove_dir_all(test_path).unwrap();
249    }
250
251    #[test]
252    fn test_ensure_git_caching() {
253        // Call ensure_git multiple times to test caching
254        let result1 = Repository::ensure_git();
255        let result2 = Repository::ensure_git();
256        let result3 = Repository::ensure_git();
257
258        assert!(result1.is_ok());
259        assert!(result2.is_ok());
260        assert!(result3.is_ok());
261    }
262
263    #[test]
264    fn test_init_with_empty_string_path() {
265        let result = Repository::init("", false);
266        // This might succeed or fail depending on git's behavior with empty paths
267        // The important thing is it doesn't panic
268        let _ = result;
269    }
270
271    #[test]
272    fn test_open_with_empty_string_path() {
273        let result = Repository::open("");
274        assert!(result.is_err());
275        
276        match result.unwrap_err() {
277            GitError::CommandFailed(msg) => {
278                assert!(msg.contains("Path does not exist") || msg.contains("Not a git repository"));
279            },
280            _ => panic!("Expected CommandFailed error"),
281        }
282    }
283
284    #[test]
285    fn test_init_with_relative_path() {
286        let test_path = "relative_test_repo";
287
288        // Clean up if exists
289        if Path::new(test_path).exists() {
290            fs::remove_dir_all(test_path).unwrap();
291        }
292
293        let result = Repository::init(test_path, false);
294        
295        if result.is_ok() {
296            let repo = result.unwrap();
297            assert_eq!(repo.repo_path(), Path::new(test_path));
298            
299            // Clean up
300            fs::remove_dir_all(test_path).unwrap();
301        }
302    }
303
304    #[test]
305    fn test_open_with_relative_path() {
306        let test_path = "relative_open_repo";
307
308        // Clean up if exists
309        if Path::new(test_path).exists() {
310            fs::remove_dir_all(test_path).unwrap();
311        }
312
313        // Create the repo first
314        let _created = Repository::init(test_path, false).unwrap();
315
316        // Now open with relative path
317        let result = Repository::open(test_path);
318        assert!(result.is_ok());
319        
320        let repo = result.unwrap();
321        assert_eq!(repo.repo_path(), Path::new(test_path));
322
323        // Clean up
324        fs::remove_dir_all(test_path).unwrap();
325    }
326
327    #[test]
328    fn test_init_with_unicode_path() {
329        let test_path = "/tmp/测试_repo_🚀";
330
331        // Clean up if exists
332        if Path::new(test_path).exists() {
333            fs::remove_dir_all(test_path).unwrap();
334        }
335
336        let result = Repository::init(test_path, false);
337        
338        if result.is_ok() {
339            let repo = result.unwrap();
340            assert_eq!(repo.repo_path(), Path::new(test_path));
341            
342            // Clean up
343            fs::remove_dir_all(test_path).unwrap();
344        }
345    }
346
347    #[test]
348    fn test_path_with_spaces() {
349        let test_path = "/tmp/test repo with spaces";
350
351        // Clean up if exists
352        if Path::new(test_path).exists() {
353            fs::remove_dir_all(test_path).unwrap();
354        }
355
356        let result = Repository::init(test_path, false);
357        
358        if result.is_ok() {
359            let repo = result.unwrap();
360            assert_eq!(repo.repo_path(), Path::new(test_path));
361            
362            // Clean up
363            fs::remove_dir_all(test_path).unwrap();
364        }
365    }
366
367    #[test]
368    fn test_very_long_path() {
369        let long_component = "a".repeat(100);
370        let test_path = format!("/tmp/{}", long_component);
371
372        // Clean up if exists
373        if Path::new(&test_path).exists() {
374            fs::remove_dir_all(&test_path).unwrap();
375        }
376
377        let result = Repository::init(&test_path, false);
378        
379        if result.is_ok() {
380            let repo = result.unwrap();
381            assert_eq!(repo.repo_path(), Path::new(&test_path));
382            
383            // Clean up
384            fs::remove_dir_all(&test_path).unwrap();
385        }
386    }
387}