use regex::Regex;
use std::error::Error;
use std::fmt;
use std::fs;
use std::path::Path;
use std::process::Command;
#[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),
}
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),
}
}
}
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,
}
}
}
pub fn parse_git_url(url: &str) -> (String, String, String) {
let https_re = Regex::new(r"https?://([^/]+)/([^/]+)/([^/\.]+)(?:\.git)?").unwrap();
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())
}
fn check_git_installed() -> Result<(), GitError> {
Command::new("git")
.arg("--version")
.output()
.map_err(GitError::GitNotInstalled)?;
Ok(())
}
#[derive(Clone)]
pub struct GitTree {
base_path: String,
}
impl GitTree {
pub fn new(base_path: &str) -> Result<Self, GitError> {
check_git_installed()?;
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(),
})
}
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();
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() {
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)
}
pub fn find(&self, pattern: &str) -> Result<Vec<GitRepo>, GitError> {
let repo_names = self.list()?;
if repo_names.is_empty() {
return Ok(Vec::new()); }
let mut matched_repos: Vec<GitRepo> = Vec::new();
if pattern == "*" {
for name in repo_names {
let full_path = format!("{}/{}", self.base_path, name);
matched_repos.push(GitRepo::new(full_path));
}
} else if pattern.ends_with('*') {
let prefix = &pattern[0..pattern.len() - 1];
for name in repo_names {
if name.starts_with(prefix) {
let full_path = format!("{}/{}", self.base_path, name);
matched_repos.push(GitRepo::new(full_path));
}
}
} else {
for name in repo_names {
if name == pattern {
let full_path = format!("{}/{}", self.base_path, name);
matched_repos.push(GitRepo::new(full_path));
}
}
}
Ok(matched_repos)
}
pub fn get(&self, path_or_url: &str) -> Result<Vec<GitRepo>, GitError> {
if path_or_url.starts_with("http") || path_or_url.starts_with("git@") {
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()));
}
let clone_path = format!("{}/{}/{}/{}", self.base_path, server, account, repo);
let clone_dir = Path::new(&clone_path);
if clone_dir.exists() {
return Ok(vec![GitRepo::new(clone_path)]);
}
if let Some(parent) = clone_dir.parent() {
fs::create_dir_all(parent).map_err(GitError::FileSystemError)?;
}
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 {
let repos = self.find(path_or_url)?;
Ok(repos)
}
}
}
pub struct GitRepo {
path: String,
}
impl GitRepo {
pub fn new(path: String) -> Self {
GitRepo { path }
}
pub fn path(&self) -> &str {
&self.path
}
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())
}
pub fn pull(&self) -> Result<Self, GitError> {
let git_dir = Path::new(&self.path).join(".git");
if !git_dir.exists() || !git_dir.is_dir() {
return Err(GitError::NotAGitRepository(self.path.clone()));
}
if self.has_changes()? {
return Err(GitError::LocalChangesExist(self.path.clone()));
}
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
)))
}
}
pub fn reset(&self) -> Result<Self, GitError> {
let git_dir = Path::new(&self.path).join(".git");
if !git_dir.exists() || !git_dir.is_dir() {
return Err(GitError::NotAGitRepository(self.path.clone()));
}
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
)));
}
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())
}
pub fn commit(&self, message: &str) -> Result<Self, GitError> {
let git_dir = Path::new(&self.path).join(".git");
if !git_dir.exists() || !git_dir.is_dir() {
return Err(GitError::NotAGitRepository(self.path.clone()));
}
if !self.has_changes()? {
return Ok(self.clone());
}
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
)));
}
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())
}
pub fn push(&self) -> Result<Self, GitError> {
let git_dir = Path::new(&self.path).join(".git");
if !git_dir.exists() || !git_dir.is_dir() {
return Err(GitError::NotAGitRepository(self.path.clone()));
}
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
)))
}
}
}
impl Clone for GitRepo {
fn clone(&self) -> Self {
GitRepo {
path: self.path.clone(),
}
}
}