1use regex::Regex;
2use std::error::Error;
3use std::fmt;
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8#[derive(Debug)]
10pub enum GitError {
11 GitNotInstalled(std::io::Error),
12 InvalidUrl(String),
13 InvalidBasePath(String),
14 HomeDirectoryNotFound(std::env::VarError),
15 FileSystemError(std::io::Error),
16 GitCommandFailed(String),
17 CommandExecutionError(std::io::Error),
18 NoRepositoriesFound,
19 RepositoryNotFound(String),
20 MultipleRepositoriesFound(String, usize),
21 NotAGitRepository(String),
22 LocalChangesExist(String),
23}
24
25impl fmt::Display for GitError {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 match self {
29 GitError::GitNotInstalled(e) => write!(f, "Git is not installed: {}", e),
30 GitError::InvalidUrl(url) => write!(f, "Could not parse git URL: {}", url),
31 GitError::InvalidBasePath(path) => write!(f, "Invalid base path: {}", path),
32 GitError::HomeDirectoryNotFound(e) => write!(f, "Could not determine home directory: {}", e),
33 GitError::FileSystemError(e) => write!(f, "Error creating directory structure: {}", e),
34 GitError::GitCommandFailed(e) => write!(f, "{}", e),
35 GitError::CommandExecutionError(e) => write!(f, "Error executing command: {}", e),
36 GitError::NoRepositoriesFound => write!(f, "No repositories found"),
37 GitError::RepositoryNotFound(pattern) => write!(f, "No repositories found matching '{}'", pattern),
38 GitError::MultipleRepositoriesFound(pattern, count) =>
39 write!(f, "Multiple repositories ({}) found matching '{}'. Use '*' suffix for multiple matches.", count, pattern),
40 GitError::NotAGitRepository(path) => write!(f, "Not a git repository at {}", path),
41 GitError::LocalChangesExist(path) => write!(f, "Repository at {} has local changes", path),
42 }
43 }
44}
45
46impl Error for GitError {
48 fn source(&self) -> Option<&(dyn Error + 'static)> {
49 match self {
50 GitError::GitNotInstalled(e) => Some(e),
51 GitError::HomeDirectoryNotFound(e) => Some(e),
52 GitError::FileSystemError(e) => Some(e),
53 GitError::CommandExecutionError(e) => Some(e),
54 _ => None,
55 }
56 }
57}
58
59pub fn parse_git_url(url: &str) -> (String, String, String) {
75 let https_re = Regex::new(r"https?://([^/]+)/([^/]+)/([^/\.]+)(?:\.git)?").unwrap();
77
78 let ssh_re = Regex::new(r"git@([^:]+):([^/]+)/([^/\.]+)(?:\.git)?").unwrap();
80
81 if let Some(caps) = https_re.captures(url) {
82 let server = caps.get(1).map_or("", |m| m.as_str()).to_string();
83 let account = caps.get(2).map_or("", |m| m.as_str()).to_string();
84 let repo = caps.get(3).map_or("", |m| m.as_str()).to_string();
85
86 return (server, account, repo);
87 } else if let Some(caps) = ssh_re.captures(url) {
88 let server = caps.get(1).map_or("", |m| m.as_str()).to_string();
89 let account = caps.get(2).map_or("", |m| m.as_str()).to_string();
90 let repo = caps.get(3).map_or("", |m| m.as_str()).to_string();
91
92 return (server, account, repo);
93 }
94
95 (String::new(), String::new(), String::new())
96}
97
98fn check_git_installed() -> Result<(), GitError> {
105 Command::new("git")
106 .arg("--version")
107 .output()
108 .map_err(GitError::GitNotInstalled)?;
109 Ok(())
110}
111
112#[derive(Clone)]
114pub struct GitTree {
115 base_path: String,
116}
117
118impl GitTree {
119 pub fn new(base_path: &str) -> Result<Self, GitError> {
130 check_git_installed()?;
132
133 let path = Path::new(base_path);
135 if !path.exists() {
136 fs::create_dir_all(path).map_err(|e| GitError::FileSystemError(e))?;
137 } else if !path.is_dir() {
138 return Err(GitError::InvalidBasePath(base_path.to_string()));
139 }
140
141 Ok(GitTree {
142 base_path: base_path.to_string(),
143 })
144 }
145
146 pub fn list(&self) -> Result<Vec<String>, GitError> {
153 let base_path = Path::new(&self.base_path);
154
155 if !base_path.exists() || !base_path.is_dir() {
156 return Ok(Vec::new());
157 }
158
159 let mut repos = Vec::new();
160
161 let output = Command::new("find")
163 .args(&[&self.base_path, "-type", "d", "-name", ".git"])
164 .output()
165 .map_err(GitError::CommandExecutionError)?;
166
167 if output.status.success() {
168 let stdout = String::from_utf8_lossy(&output.stdout);
169 for line in stdout.lines() {
170 if let Some(parent) = Path::new(line).parent() {
172 if let Some(path_str) = parent.to_str() {
173 repos.push(path_str.to_string());
174 }
175 }
176 }
177 } else {
178 let error = String::from_utf8_lossy(&output.stderr);
179 return Err(GitError::GitCommandFailed(format!(
180 "Failed to find git repositories: {}",
181 error
182 )));
183 }
184
185 Ok(repos)
186 }
187
188 pub fn find(&self, pattern: &str) -> Result<Vec<GitRepo>, GitError> {
202 let repo_names = self.list()?; if repo_names.is_empty() {
205 return Ok(Vec::new()); }
207
208 let mut matched_repos: Vec<GitRepo> = Vec::new();
209
210 if pattern == "*" {
211 for name in repo_names {
212 let full_path = format!("{}/{}", self.base_path, name);
213 matched_repos.push(GitRepo::new(full_path));
214 }
215 } else if pattern.ends_with('*') {
216 let prefix = &pattern[0..pattern.len() - 1];
217 for name in repo_names {
218 if name.starts_with(prefix) {
219 let full_path = format!("{}/{}", self.base_path, name);
220 matched_repos.push(GitRepo::new(full_path));
221 }
222 }
223 } else {
224 for name in repo_names {
226 if name == pattern {
227 let full_path = format!("{}/{}", self.base_path, name);
228 matched_repos.push(GitRepo::new(full_path));
229 }
232 }
233 }
234
235 Ok(matched_repos)
236 }
237
238 pub fn get(&self, path_or_url: &str) -> Result<Vec<GitRepo>, GitError> {
251 if path_or_url.starts_with("http") || path_or_url.starts_with("git@") {
253 let (server, account, repo) = parse_git_url(path_or_url);
255 if server.is_empty() || account.is_empty() || repo.is_empty() {
256 return Err(GitError::InvalidUrl(path_or_url.to_string()));
257 }
258
259 let clone_path = format!("{}/{}/{}/{}", self.base_path, server, account, repo);
261 let clone_dir = Path::new(&clone_path);
262
263 if clone_dir.exists() {
265 return Ok(vec![GitRepo::new(clone_path)]);
266 }
267
268 if let Some(parent) = clone_dir.parent() {
270 fs::create_dir_all(parent).map_err(GitError::FileSystemError)?;
271 }
272
273 let output = Command::new("git")
275 .args(&["clone", "--depth", "1", path_or_url, &clone_path])
276 .output()
277 .map_err(GitError::CommandExecutionError)?;
278
279 if output.status.success() {
280 Ok(vec![GitRepo::new(clone_path)])
281 } else {
282 let error = String::from_utf8_lossy(&output.stderr);
283 Err(GitError::GitCommandFailed(format!(
284 "Git clone error: {}",
285 error
286 )))
287 }
288 } else {
289 let repos = self.find(path_or_url)?;
292 Ok(repos)
293 }
294 }
295}
296
297pub struct GitRepo {
299 path: String,
300}
301
302impl GitRepo {
303 pub fn new(path: String) -> Self {
309 GitRepo { path }
310 }
311
312 pub fn path(&self) -> &str {
318 &self.path
319 }
320
321 pub fn has_changes(&self) -> Result<bool, GitError> {
328 let output = Command::new("git")
329 .args(&["-C", &self.path, "status", "--porcelain"])
330 .output()
331 .map_err(GitError::CommandExecutionError)?;
332
333 Ok(!output.stdout.is_empty())
334 }
335
336 pub fn pull(&self) -> Result<Self, GitError> {
343 let git_dir = Path::new(&self.path).join(".git");
345 if !git_dir.exists() || !git_dir.is_dir() {
346 return Err(GitError::NotAGitRepository(self.path.clone()));
347 }
348
349 if self.has_changes()? {
351 return Err(GitError::LocalChangesExist(self.path.clone()));
352 }
353
354 let output = Command::new("git")
356 .args(&["-C", &self.path, "pull"])
357 .output()
358 .map_err(GitError::CommandExecutionError)?;
359
360 if output.status.success() {
361 Ok(self.clone())
362 } else {
363 let error = String::from_utf8_lossy(&output.stderr);
364 Err(GitError::GitCommandFailed(format!(
365 "Git pull error: {}",
366 error
367 )))
368 }
369 }
370
371 pub fn reset(&self) -> Result<Self, GitError> {
378 let git_dir = Path::new(&self.path).join(".git");
380 if !git_dir.exists() || !git_dir.is_dir() {
381 return Err(GitError::NotAGitRepository(self.path.clone()));
382 }
383
384 let reset_output = Command::new("git")
386 .args(&["-C", &self.path, "reset", "--hard", "HEAD"])
387 .output()
388 .map_err(GitError::CommandExecutionError)?;
389
390 if !reset_output.status.success() {
391 let error = String::from_utf8_lossy(&reset_output.stderr);
392 return Err(GitError::GitCommandFailed(format!(
393 "Git reset error: {}",
394 error
395 )));
396 }
397
398 let clean_output = Command::new("git")
400 .args(&["-C", &self.path, "clean", "-fd"])
401 .output()
402 .map_err(GitError::CommandExecutionError)?;
403
404 if !clean_output.status.success() {
405 let error = String::from_utf8_lossy(&clean_output.stderr);
406 return Err(GitError::GitCommandFailed(format!(
407 "Git clean error: {}",
408 error
409 )));
410 }
411
412 Ok(self.clone())
413 }
414
415 pub fn commit(&self, message: &str) -> Result<Self, GitError> {
426 let git_dir = Path::new(&self.path).join(".git");
428 if !git_dir.exists() || !git_dir.is_dir() {
429 return Err(GitError::NotAGitRepository(self.path.clone()));
430 }
431
432 if !self.has_changes()? {
434 return Ok(self.clone());
435 }
436
437 let add_output = Command::new("git")
439 .args(&["-C", &self.path, "add", "."])
440 .output()
441 .map_err(GitError::CommandExecutionError)?;
442
443 if !add_output.status.success() {
444 let error = String::from_utf8_lossy(&add_output.stderr);
445 return Err(GitError::GitCommandFailed(format!(
446 "Git add error: {}",
447 error
448 )));
449 }
450
451 let commit_output = Command::new("git")
453 .args(&["-C", &self.path, "commit", "-m", message])
454 .output()
455 .map_err(GitError::CommandExecutionError)?;
456
457 if !commit_output.status.success() {
458 let error = String::from_utf8_lossy(&commit_output.stderr);
459 return Err(GitError::GitCommandFailed(format!(
460 "Git commit error: {}",
461 error
462 )));
463 }
464
465 Ok(self.clone())
466 }
467
468 pub fn push(&self) -> Result<Self, GitError> {
475 let git_dir = Path::new(&self.path).join(".git");
477 if !git_dir.exists() || !git_dir.is_dir() {
478 return Err(GitError::NotAGitRepository(self.path.clone()));
479 }
480
481 let push_output = Command::new("git")
483 .args(&["-C", &self.path, "push"])
484 .output()
485 .map_err(GitError::CommandExecutionError)?;
486
487 if push_output.status.success() {
488 Ok(self.clone())
489 } else {
490 let error = String::from_utf8_lossy(&push_output.stderr);
491 Err(GitError::GitCommandFailed(format!(
492 "Git push error: {}",
493 error
494 )))
495 }
496 }
497}
498
499impl Clone for GitRepo {
501 fn clone(&self) -> Self {
502 GitRepo {
503 path: self.path.clone(),
504 }
505 }
506}