490 lines
17 KiB
Rust
490 lines
17 KiB
Rust
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<Self, GitError> {
|
|
// 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<String>)` - A vector of paths to git repositories
|
|
/// * `Err(GitError)` - If the operation failed
|
|
pub fn list(&self) -> Result<Vec<String>, 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<String>)` - 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<Vec<String>, 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<String> = 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<String> = 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<GitRepo>)` - 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<Vec<GitRepo>, 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<GitRepo> = 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<bool, GitError> {
|
|
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<Self, GitError> {
|
|
// 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<Self, GitError> {
|
|
// 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<Self, GitError> {
|
|
// 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<Self, GitError> {
|
|
// 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(),
|
|
}
|
|
}
|
|
}
|