sal_git/
git.rs

1use regex::Regex;
2use std::error::Error;
3use std::fmt;
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8// Define a custom error type for git operations
9#[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
25// Implement Display for GitError
26impl 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
46// Implement Error trait for GitError
47impl 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
59/// Parses a git URL to extract the server, account, and repository name.
60///
61/// # Arguments
62///
63/// * `url` - The URL of the git repository to parse. Can be in HTTPS format
64///   (https://github.com/username/repo.git) or SSH format (git@github.com:username/repo.git).
65///
66/// # Returns
67///
68/// A tuple containing:
69/// * `server` - The server name (e.g., "github.com")
70/// * `account` - The account or organization name (e.g., "username")
71/// * `repo` - The repository name (e.g., "repo")
72///
73/// If the URL cannot be parsed, all three values will be empty strings.
74pub fn parse_git_url(url: &str) -> (String, String, String) {
75    // HTTP(S) URL format: https://github.com/username/repo.git
76    let https_re = Regex::new(r"https?://([^/]+)/([^/]+)/([^/\.]+)(?:\.git)?").unwrap();
77
78    // SSH URL format: git@github.com:username/repo.git
79    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
98/// Checks if git is installed on the system.
99///
100/// # Returns
101///
102/// * `Ok(())` - If git is installed
103/// * `Err(GitError)` - If git is not installed
104fn check_git_installed() -> Result<(), GitError> {
105    Command::new("git")
106        .arg("--version")
107        .output()
108        .map_err(GitError::GitNotInstalled)?;
109    Ok(())
110}
111
112/// Represents a collection of git repositories under a base path.
113#[derive(Clone)]
114pub struct GitTree {
115    base_path: String,
116}
117
118impl GitTree {
119    /// Creates a new GitTree with the specified base path.
120    ///
121    /// # Arguments
122    ///
123    /// * `base_path` - The base path where all git repositories are located
124    ///
125    /// # Returns
126    ///
127    /// * `Ok(GitTree)` - A new GitTree instance
128    /// * `Err(GitError)` - If the base path is invalid or cannot be created
129    pub fn new(base_path: &str) -> Result<Self, GitError> {
130        // Check if git is installed
131        check_git_installed()?;
132
133        // Validate the base path
134        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    /// Lists all git repositories under the base path.
147    ///
148    /// # Returns
149    ///
150    /// * `Ok(Vec<String>)` - A vector of paths to git repositories
151    /// * `Err(GitError)` - If the operation failed
152    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        // Find all directories with .git subdirectories
162        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                // Get the parent directory of .git which is the repo root
171                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    /// Finds repositories matching a pattern or partial path.
189    ///
190    /// # Arguments
191    ///
192    /// * `pattern` - The pattern to match against repository paths
193    ///   - If the pattern ends with '*', all matching repositories are returned
194    ///   - Otherwise, exactly one matching repository must be found
195    ///
196    /// # Returns
197    ///
198    /// * `Ok(Vec<String>)` - A vector of paths to matching repositories
199    /// * `Err(GitError)` - If no matching repositories are found,
200    ///   or if multiple repositories match a non-wildcard pattern
201    pub fn find(&self, pattern: &str) -> Result<Vec<GitRepo>, GitError> {
202        let repo_names = self.list()?; // list() already ensures these are git repo names
203
204        if repo_names.is_empty() {
205            return Ok(Vec::new()); // If no repos listed, find results in an empty list
206        }
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            // Exact match for the name
225            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                    // `find` returns all exact matches. If names aren't unique (unlikely from `list`),
230                    // it could return more than one. For an exact name, typically one is expected.
231                }
232            }
233        }
234
235        Ok(matched_repos)
236    }
237
238    /// Gets one or more GitRepo objects based on a path pattern or URL.
239    ///
240    /// # Arguments
241    ///
242    /// * `path_or_url` - The path pattern to match against repository paths or a git URL
243    ///   - If it's a URL, the repository will be cloned if it doesn't exist
244    ///   - If it's a path pattern, it will find matching repositories
245    ///
246    /// # Returns
247    ///
248    /// * `Ok(Vec<GitRepo>)` - A vector of GitRepo objects
249    /// * `Err(GitError)` - If no matching repositories are found or the clone operation failed
250    pub fn get(&self, path_or_url: &str) -> Result<Vec<GitRepo>, GitError> {
251        // Check if it's a URL
252        if path_or_url.starts_with("http") || path_or_url.starts_with("git@") {
253            // Parse the URL
254            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            // Create the target directory
260            let clone_path = format!("{}/{}/{}/{}", self.base_path, server, account, repo);
261            let clone_dir = Path::new(&clone_path);
262
263            // Check if repo already exists
264            if clone_dir.exists() {
265                return Ok(vec![GitRepo::new(clone_path)]);
266            }
267
268            // Create parent directory
269            if let Some(parent) = clone_dir.parent() {
270                fs::create_dir_all(parent).map_err(GitError::FileSystemError)?;
271            }
272
273            // Clone the repository
274            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            // It's a path pattern, find matching repositories using the updated self.find()
290            // which now directly returns Result<Vec<GitRepo>, GitError>.
291            let repos = self.find(path_or_url)?;
292            Ok(repos)
293        }
294    }
295}
296
297/// Represents a git repository.
298pub struct GitRepo {
299    path: String,
300}
301
302impl GitRepo {
303    /// Creates a new GitRepo with the specified path.
304    ///
305    /// # Arguments
306    ///
307    /// * `path` - The path to the git repository
308    pub fn new(path: String) -> Self {
309        GitRepo { path }
310    }
311
312    /// Gets the path of the repository.
313    ///
314    /// # Returns
315    ///
316    /// * The path to the git repository
317    pub fn path(&self) -> &str {
318        &self.path
319    }
320
321    /// Checks if the repository has uncommitted changes.
322    ///
323    /// # Returns
324    ///
325    /// * `Ok(bool)` - True if the repository has uncommitted changes, false otherwise
326    /// * `Err(GitError)` - If the operation failed
327    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    /// Pulls the latest changes from the remote repository.
337    ///
338    /// # Returns
339    ///
340    /// * `Ok(Self)` - The GitRepo object for method chaining
341    /// * `Err(GitError)` - If the pull operation failed
342    pub fn pull(&self) -> Result<Self, GitError> {
343        // Check if repository exists and is a git repository
344        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        // Check for local changes
350        if self.has_changes()? {
351            return Err(GitError::LocalChangesExist(self.path.clone()));
352        }
353
354        // Pull the latest changes
355        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    /// Resets any local changes in the repository.
372    ///
373    /// # Returns
374    ///
375    /// * `Ok(Self)` - The GitRepo object for method chaining
376    /// * `Err(GitError)` - If the reset operation failed
377    pub fn reset(&self) -> Result<Self, GitError> {
378        // Check if repository exists and is a git repository
379        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        // Reset any local changes
385        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        // Clean untracked files
399        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    /// Commits changes in the repository.
416    ///
417    /// # Arguments
418    ///
419    /// * `message` - The commit message
420    ///
421    /// # Returns
422    ///
423    /// * `Ok(Self)` - The GitRepo object for method chaining
424    /// * `Err(GitError)` - If the commit operation failed
425    pub fn commit(&self, message: &str) -> Result<Self, GitError> {
426        // Check if repository exists and is a git repository
427        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        // Check for local changes
433        if !self.has_changes()? {
434            return Ok(self.clone());
435        }
436
437        // Add all changes
438        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        // Commit the changes
452        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    /// Pushes changes to the remote repository.
469    ///
470    /// # Returns
471    ///
472    /// * `Ok(Self)` - The GitRepo object for method chaining
473    /// * `Err(GitError)` - If the push operation failed
474    pub fn push(&self) -> Result<Self, GitError> {
475        // Check if repository exists and is a git repository
476        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        // Push the changes
482        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
499// Implement Clone for GitRepo to allow for method chaining
500impl Clone for GitRepo {
501    fn clone(&self) -> Self {
502        GitRepo {
503            path: self.path.clone(),
504        }
505    }
506}