use std::process::Command; use std::path::Path; use std::fs; use std::env; use regex::Regex; use std::fmt; use std::error::Error; // Define a custom error type for git operations #[derive(Debug)] pub enum GitError { GitNotInstalled(std::io::Error), InvalidUrl(String), InvalidBasePath(String), HomeDirectoryNotFound(std::env::VarError), FileSystemError(std::io::Error), GitCommandFailed(String), CommandExecutionError(std::io::Error), NoRepositoriesFound, RepositoryNotFound(String), MultipleRepositoriesFound(String, usize), NotAGitRepository(String), LocalChangesExist(String), } // Implement Display for GitError impl fmt::Display for GitError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { GitError::GitNotInstalled(e) => write!(f, "Git is not installed: {}", e), GitError::InvalidUrl(url) => write!(f, "Could not parse git URL: {}", url), GitError::InvalidBasePath(path) => write!(f, "Invalid base path: {}", path), GitError::HomeDirectoryNotFound(e) => write!(f, "Could not determine home directory: {}", e), GitError::FileSystemError(e) => write!(f, "Error creating directory structure: {}", e), GitError::GitCommandFailed(e) => write!(f, "{}", e), GitError::CommandExecutionError(e) => write!(f, "Error executing command: {}", e), GitError::NoRepositoriesFound => write!(f, "No repositories found"), GitError::RepositoryNotFound(pattern) => write!(f, "No repositories found matching '{}'", pattern), GitError::MultipleRepositoriesFound(pattern, count) => write!(f, "Multiple repositories ({}) found matching '{}'. Use '*' suffix for multiple matches.", count, pattern), GitError::NotAGitRepository(path) => write!(f, "Not a git repository at {}", path), GitError::LocalChangesExist(path) => write!(f, "Repository at {} has local changes", path), } } } // Implement Error trait for GitError impl Error for GitError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { GitError::GitNotInstalled(e) => Some(e), GitError::HomeDirectoryNotFound(e) => Some(e), GitError::FileSystemError(e) => Some(e), GitError::CommandExecutionError(e) => Some(e), _ => None, } } } /// Parses a git URL to extract the server, account, and repository name. /// /// # Arguments /// /// * `url` - The URL of the git repository to parse. Can be in HTTPS format /// (https://github.com/username/repo.git) or SSH format (git@github.com:username/repo.git). /// /// # Returns /// /// A tuple containing: /// * `server` - The server name (e.g., "github.com") /// * `account` - The account or organization name (e.g., "username") /// * `repo` - The repository name (e.g., "repo") /// /// If the URL cannot be parsed, all three values will be empty strings. pub fn parse_git_url(url: &str) -> (String, String, String) { // HTTP(S) URL format: https://github.com/username/repo.git let https_re = Regex::new(r"https?://([^/]+)/([^/]+)/([^/\.]+)(?:\.git)?").unwrap(); // SSH URL format: git@github.com:username/repo.git let ssh_re = Regex::new(r"git@([^:]+):([^/]+)/([^/\.]+)(?:\.git)?").unwrap(); if let Some(caps) = https_re.captures(url) { let server = caps.get(1).map_or("", |m| m.as_str()).to_string(); let account = caps.get(2).map_or("", |m| m.as_str()).to_string(); let repo = caps.get(3).map_or("", |m| m.as_str()).to_string(); return (server, account, repo); } else if let Some(caps) = ssh_re.captures(url) { let server = caps.get(1).map_or("", |m| m.as_str()).to_string(); let account = caps.get(2).map_or("", |m| m.as_str()).to_string(); let repo = caps.get(3).map_or("", |m| m.as_str()).to_string(); return (server, account, repo); } (String::new(), String::new(), String::new()) } /// Checks if git is installed on the system. /// /// # Returns /// /// * `Ok(())` - If git is installed /// * `Err(GitError)` - If git is not installed fn check_git_installed() -> Result<(), GitError> { Command::new("git") .arg("--version") .output() .map_err(GitError::GitNotInstalled)?; Ok(()) } /// Represents a collection of git repositories under a base path. #[derive(Clone)] pub struct GitTree { base_path: String, } impl GitTree { /// Creates a new GitTree with the specified base path. /// /// # Arguments /// /// * `base_path` - The base path where all git repositories are located /// /// # Returns /// /// * `Ok(GitTree)` - A new GitTree instance /// * `Err(GitError)` - If the base path is invalid or cannot be created pub fn new(base_path: &str) -> Result { // Check if git is installed check_git_installed()?; // Validate the base path let path = Path::new(base_path); if !path.exists() { fs::create_dir_all(path).map_err(|e| { GitError::FileSystemError(e) })?; } else if !path.is_dir() { return Err(GitError::InvalidBasePath(base_path.to_string())); } Ok(GitTree { base_path: base_path.to_string(), }) } /// Lists all git repositories under the base path. /// /// # Returns /// /// * `Ok(Vec)` - A vector of paths to git repositories /// * `Err(GitError)` - If the operation failed pub fn list(&self) -> Result, GitError> { let base_path = Path::new(&self.base_path); if !base_path.exists() || !base_path.is_dir() { return Ok(Vec::new()); } let mut repos = Vec::new(); // Find all directories with .git subdirectories let output = Command::new("find") .args(&[&self.base_path, "-type", "d", "-name", ".git"]) .output() .map_err(GitError::CommandExecutionError)?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines() { // Get the parent directory of .git which is the repo root if let Some(parent) = Path::new(line).parent() { if let Some(path_str) = parent.to_str() { repos.push(path_str.to_string()); } } } } else { let error = String::from_utf8_lossy(&output.stderr); return Err(GitError::GitCommandFailed(format!("Failed to find git repositories: {}", error))); } Ok(repos) } /// Finds repositories matching a pattern or partial path. /// /// # Arguments /// /// * `pattern` - The pattern to match against repository paths /// - If the pattern ends with '*', all matching repositories are returned /// - Otherwise, exactly one matching repository must be found /// /// # Returns /// /// * `Ok(Vec)` - A vector of paths to matching repositories /// * `Err(GitError)` - If no matching repositories are found, /// or if multiple repositories match a non-wildcard pattern pub fn find(&self, pattern: &str) -> Result, GitError> { // Get all repos let repos = self.list()?; if repos.is_empty() { return Err(GitError::NoRepositoriesFound); } // Check if pattern ends with wildcard if pattern.ends_with('*') { let search_pattern = &pattern[0..pattern.len()-1]; // Remove the * let matching: Vec = repos.iter() .filter(|repo| repo.contains(search_pattern)) .cloned() .collect(); if matching.is_empty() { return Err(GitError::RepositoryNotFound(pattern.to_string())); } Ok(matching) } else { // No wildcard, need to find exactly one match let matching: Vec = repos.iter() .filter(|repo| repo.contains(pattern)) .cloned() .collect(); match matching.len() { 0 => Err(GitError::RepositoryNotFound(pattern.to_string())), 1 => Ok(matching), _ => Err(GitError::MultipleRepositoriesFound(pattern.to_string(), matching.len())), } } } /// Gets one or more GitRepo objects based on a path pattern or URL. /// /// # Arguments /// /// * `path_or_url` - The path pattern to match against repository paths or a git URL /// - If it's a URL, the repository will be cloned if it doesn't exist /// - If it's a path pattern, it will find matching repositories /// /// # Returns /// /// * `Ok(Vec)` - A vector of GitRepo objects /// * `Err(GitError)` - If no matching repositories are found or the clone operation failed pub fn get(&self, path_or_url: &str) -> Result, GitError> { // Check if it's a URL if path_or_url.starts_with("http") || path_or_url.starts_with("git@") { // Parse the URL let (server, account, repo) = parse_git_url(path_or_url); if server.is_empty() || account.is_empty() || repo.is_empty() { return Err(GitError::InvalidUrl(path_or_url.to_string())); } // Create the target directory let clone_path = format!("{}/{}/{}/{}", self.base_path, server, account, repo); let clone_dir = Path::new(&clone_path); // Check if repo already exists if clone_dir.exists() { return Ok(vec![GitRepo::new(clone_path)]); } // Create parent directory if let Some(parent) = clone_dir.parent() { fs::create_dir_all(parent).map_err(GitError::FileSystemError)?; } // Clone the repository let output = Command::new("git") .args(&["clone", "--depth", "1", path_or_url, &clone_path]) .output() .map_err(GitError::CommandExecutionError)?; if output.status.success() { Ok(vec![GitRepo::new(clone_path)]) } else { let error = String::from_utf8_lossy(&output.stderr); Err(GitError::GitCommandFailed(format!("Git clone error: {}", error))) } } else { // It's a path pattern, find matching repositories let repo_paths = self.find(path_or_url)?; // Convert paths to GitRepo objects let repos: Vec = repo_paths.into_iter() .map(GitRepo::new) .collect(); Ok(repos) } } } /// Represents a git repository. pub struct GitRepo { path: String, } impl GitRepo { /// Creates a new GitRepo with the specified path. /// /// # Arguments /// /// * `path` - The path to the git repository pub fn new(path: String) -> Self { GitRepo { path } } /// Gets the path of the repository. /// /// # Returns /// /// * The path to the git repository pub fn path(&self) -> &str { &self.path } /// Checks if the repository has uncommitted changes. /// /// # Returns /// /// * `Ok(bool)` - True if the repository has uncommitted changes, false otherwise /// * `Err(GitError)` - If the operation failed pub fn has_changes(&self) -> Result { let output = Command::new("git") .args(&["-C", &self.path, "status", "--porcelain"]) .output() .map_err(GitError::CommandExecutionError)?; Ok(!output.stdout.is_empty()) } /// Pulls the latest changes from the remote repository. /// /// # Returns /// /// * `Ok(Self)` - The GitRepo object for method chaining /// * `Err(GitError)` - If the pull operation failed pub fn pull(&self) -> Result { // Check if repository exists and is a git repository let git_dir = Path::new(&self.path).join(".git"); if !git_dir.exists() || !git_dir.is_dir() { return Err(GitError::NotAGitRepository(self.path.clone())); } // Check for local changes if self.has_changes()? { return Err(GitError::LocalChangesExist(self.path.clone())); } // Pull the latest changes let output = Command::new("git") .args(&["-C", &self.path, "pull"]) .output() .map_err(GitError::CommandExecutionError)?; if output.status.success() { Ok(self.clone()) } else { let error = String::from_utf8_lossy(&output.stderr); Err(GitError::GitCommandFailed(format!("Git pull error: {}", error))) } } /// Resets any local changes in the repository. /// /// # Returns /// /// * `Ok(Self)` - The GitRepo object for method chaining /// * `Err(GitError)` - If the reset operation failed pub fn reset(&self) -> Result { // Check if repository exists and is a git repository let git_dir = Path::new(&self.path).join(".git"); if !git_dir.exists() || !git_dir.is_dir() { return Err(GitError::NotAGitRepository(self.path.clone())); } // Reset any local changes let reset_output = Command::new("git") .args(&["-C", &self.path, "reset", "--hard", "HEAD"]) .output() .map_err(GitError::CommandExecutionError)?; if !reset_output.status.success() { let error = String::from_utf8_lossy(&reset_output.stderr); return Err(GitError::GitCommandFailed(format!("Git reset error: {}", error))); } // Clean untracked files let clean_output = Command::new("git") .args(&["-C", &self.path, "clean", "-fd"]) .output() .map_err(GitError::CommandExecutionError)?; if !clean_output.status.success() { let error = String::from_utf8_lossy(&clean_output.stderr); return Err(GitError::GitCommandFailed(format!("Git clean error: {}", error))); } Ok(self.clone()) } /// Commits changes in the repository. /// /// # Arguments /// /// * `message` - The commit message /// /// # Returns /// /// * `Ok(Self)` - The GitRepo object for method chaining /// * `Err(GitError)` - If the commit operation failed pub fn commit(&self, message: &str) -> Result { // Check if repository exists and is a git repository let git_dir = Path::new(&self.path).join(".git"); if !git_dir.exists() || !git_dir.is_dir() { return Err(GitError::NotAGitRepository(self.path.clone())); } // Check for local changes if !self.has_changes()? { return Ok(self.clone()); } // Add all changes let add_output = Command::new("git") .args(&["-C", &self.path, "add", "."]) .output() .map_err(GitError::CommandExecutionError)?; if !add_output.status.success() { let error = String::from_utf8_lossy(&add_output.stderr); return Err(GitError::GitCommandFailed(format!("Git add error: {}", error))); } // Commit the changes let commit_output = Command::new("git") .args(&["-C", &self.path, "commit", "-m", message]) .output() .map_err(GitError::CommandExecutionError)?; if !commit_output.status.success() { let error = String::from_utf8_lossy(&commit_output.stderr); return Err(GitError::GitCommandFailed(format!("Git commit error: {}", error))); } Ok(self.clone()) } /// Pushes changes to the remote repository. /// /// # Returns /// /// * `Ok(Self)` - The GitRepo object for method chaining /// * `Err(GitError)` - If the push operation failed pub fn push(&self) -> Result { // Check if repository exists and is a git repository let git_dir = Path::new(&self.path).join(".git"); if !git_dir.exists() || !git_dir.is_dir() { return Err(GitError::NotAGitRepository(self.path.clone())); } // Push the changes let push_output = Command::new("git") .args(&["-C", &self.path, "push"]) .output() .map_err(GitError::CommandExecutionError)?; if push_output.status.success() { Ok(self.clone()) } else { let error = String::from_utf8_lossy(&push_output.stderr); Err(GitError::GitCommandFailed(format!("Git push error: {}", error))) } } } // Implement Clone for GitRepo to allow for method chaining impl Clone for GitRepo { fn clone(&self) -> Self { GitRepo { path: self.path.clone(), } } }