feat: Convert SAL to a Rust monorepo
Some checks are pending
Rhai Tests / Run Rhai Tests (push) Waiting to run

- Migrate SAL project from single-crate to monorepo structure
- Create independent packages for individual modules
- Improve build efficiency and testing capabilities
- Update documentation to reflect new structure
- Successfully convert the git module to an independent package.
This commit is contained in:
Mahmoud-Emad
2025-06-18 14:12:36 +03:00
parent ba9103685f
commit e031b03e04
20 changed files with 790 additions and 194 deletions

18
git/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "sal-git"
version = "0.1.0"
edition = "2021"
authors = ["PlanetFirst <info@incubaid.com>"]
description = "SAL Git - Git repository management and operations"
repository = "https://git.threefold.info/herocode/sal"
license = "Apache-2.0"
[dependencies]
regex = "1.8.1"
redis = "0.31.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rhai = { version = "1.12.0", features = ["sync"] }
[dev-dependencies]
tempfile = "3.5"

86
git/README.md Normal file
View File

@@ -0,0 +1,86 @@
# SAL `git` Module
The `git` module in SAL provides comprehensive functionalities for interacting with Git repositories. It offers both high-level abstractions for common Git workflows and a flexible executor for running arbitrary Git commands with integrated authentication.
This module is central to SAL's capabilities for managing source code, enabling automation of development tasks, and integrating with version control systems.
## Core Components
The module is primarily composed of two main parts:
1. **Repository and Tree Management (`git.rs`)**: Defines `GitTree` and `GitRepo` structs for a more structured, object-oriented approach to Git operations.
2. **Command Execution with Authentication (`git_executor.rs`)**: Provides `GitExecutor` for running any Git command, with a focus on handling authentication via configurations stored in Redis.
### 1. Repository and Tree Management (`GitTree` & `GitRepo`)
These components allow for programmatic management of Git repositories.
* **`GitTree`**: Represents a directory (base path) that can contain multiple Git repositories.
* `new(base_path)`: Creates a new `GitTree` instance for the given base path.
* `list()`: Lists all Git repositories found under the base path.
* `find(pattern)`: Finds repositories within the tree that match a given name pattern (supports wildcards).
* `get(path_or_url)`: Retrieves `GitRepo` instances. If a local path/pattern is given, it finds existing repositories. If a Git URL is provided, it will clone the repository into a structured path (`base_path/server/account/repo`) if it doesn't already exist.
* **`GitRepo`**: Represents a single Git repository.
* `new(path)`: Creates a `GitRepo` instance for the repository at the given path.
* `path()`: Returns the local file system path to the repository.
* `has_changes()`: Checks if the repository has uncommitted local changes.
* `pull()`: Pulls the latest changes from the remote. Fails if local changes exist.
* `reset()`: Performs a hard reset (`git reset --hard HEAD`) and cleans untracked files (`git clean -fd`).
* `commit(message)`: Stages all changes (`git add .`) and commits them with the given message.
* `push()`: Pushes committed changes to the remote repository.
* **`GitError`**: A comprehensive enum for errors related to `GitTree` and `GitRepo` operations (e.g., Git not installed, invalid URL, repository not found, local changes exist).
* **`parse_git_url(url)`**: A utility function to parse HTTPS and SSH Git URLs into server, account, and repository name components.
### 2. Command Execution with Authentication (`GitExecutor`)
`GitExecutor` is designed for flexible execution of any Git command, with a special emphasis on handling authentication for remote operations.
* **`GitExecutor::new()` / `GitExecutor::default()`**: Creates a new executor instance.
* **`GitExecutor::init()`**: Initializes the executor by attempting to load authentication configurations from Redis (key: `herocontext:git`). If Redis is unavailable or the config is missing, it proceeds without specific auth configurations, relying on system defaults.
* **`GitExecutor::execute(args: &[&str])`**: The primary method to run a Git command (e.g., `executor.execute(&["clone", "https://github.com/user/repo.git", "myrepo"])`).
* It intelligently attempts to apply authentication based on the command and the loaded configuration.
#### Authentication Configuration (`herocontext:git` in Redis)
The `GitExecutor` can load its authentication settings from a JSON object stored in Redis under the key `herocontext:git`. The structure is as follows:
```json
{
"status": "ok", // or "error"
"auth": {
"github.com": {
"sshagent": true // Use SSH agent for github.com
},
"gitlab.example.com": {
"key": "/path/to/ssh/key_for_gitlab" // Use specific SSH key
},
"dev.azure.com": {
"username": "your_username",
"password": "your_pat_or_password" // Use HTTPS credentials
}
// ... other server configurations
}
}
```
* **Authentication Methods Supported**:
* **SSH Agent**: If `sshagent: true` is set for a server, and an SSH agent is loaded with identities.
* **SSH Key**: If `key: "/path/to/key"` is specified, `GIT_SSH_COMMAND` is used to point to this key.
* **Username/Password (HTTPS)**: If `username` and `password` are provided, HTTPS URLs are rewritten to include these credentials (e.g., `https://user:pass@server/repo.git`).
* **`GitExecutorError`**: An enum for errors specific to `GitExecutor`, including command failures, Redis errors, JSON parsing issues, and authentication problems (e.g., `SshAgentNotLoaded`, `InvalidAuthConfig`).
## Usage with `herodo`
The `herodo` CLI tool likely leverages `GitExecutor` to provide its scriptable Git functionalities. This allows Rhai scripts executed by `herodo` to perform Git operations using the centrally managed authentication configurations from Redis, promoting secure and consistent access to Git repositories.
## Error Handling
Both `git.rs` and `git_executor.rs` define their own specific error enums (`GitError` and `GitExecutorError` respectively) to provide detailed information about issues encountered during Git operations. These errors cover a wide range of scenarios from command execution failures to authentication problems and invalid configurations.
## Summary
The `git` module offers a powerful and flexible interface to Git, catering to both simple, high-level repository interactions and complex, authenticated command execution scenarios. Its integration with Redis for authentication configuration makes it particularly well-suited for automated systems and tools like `herodo`.

484
git/src/git.rs Normal file
View File

@@ -0,0 +1,484 @@
use std::process::Command;
use std::path::Path;
use std::fs;
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<GitRepo>, GitError> {
let repo_names = self.list()?; // list() already ensures these are git repo names
if repo_names.is_empty() {
return Ok(Vec::new()); // If no repos listed, find results in an empty list
}
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 {
// Exact match for the name
for name in repo_names {
if name == pattern {
let full_path = format!("{}/{}", self.base_path, name);
matched_repos.push(GitRepo::new(full_path));
// `find` returns all exact matches. If names aren't unique (unlikely from `list`),
// it could return more than one. For an exact name, typically one is expected.
}
}
}
Ok(matched_repos)
}
/// 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 using the updated self.find()
// which now directly returns Result<Vec<GitRepo>, GitError>.
let repos = self.find(path_or_url)?;
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(),
}
}
}

375
git/src/git_executor.rs Normal file
View File

@@ -0,0 +1,375 @@
use redis::Cmd;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::error::Error;
use std::fmt;
use std::process::{Command, Output};
// Simple redis client functionality
fn execute_redis_command(cmd: &mut redis::Cmd) -> redis::RedisResult<String> {
// Try to connect to Redis with default settings
let client = redis::Client::open("redis://127.0.0.1/")?;
let mut con = client.get_connection()?;
cmd.query(&mut con)
}
// Define a custom error type for GitExecutor operations
#[derive(Debug)]
pub enum GitExecutorError {
GitCommandFailed(String),
CommandExecutionError(std::io::Error),
RedisError(redis::RedisError),
JsonError(serde_json::Error),
AuthenticationError(String),
SshAgentNotLoaded,
InvalidAuthConfig(String),
}
// Implement Display for GitExecutorError
impl fmt::Display for GitExecutorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GitExecutorError::GitCommandFailed(e) => write!(f, "Git command failed: {}", e),
GitExecutorError::CommandExecutionError(e) => {
write!(f, "Command execution error: {}", e)
}
GitExecutorError::RedisError(e) => write!(f, "Redis error: {}", e),
GitExecutorError::JsonError(e) => write!(f, "JSON error: {}", e),
GitExecutorError::AuthenticationError(e) => write!(f, "Authentication error: {}", e),
GitExecutorError::SshAgentNotLoaded => write!(f, "SSH agent is not loaded"),
GitExecutorError::InvalidAuthConfig(e) => {
write!(f, "Invalid authentication configuration: {}", e)
}
}
}
}
// Implement Error trait for GitExecutorError
impl Error for GitExecutorError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
GitExecutorError::CommandExecutionError(e) => Some(e),
GitExecutorError::RedisError(e) => Some(e),
GitExecutorError::JsonError(e) => Some(e),
_ => None,
}
}
}
// From implementations for error conversion
impl From<redis::RedisError> for GitExecutorError {
fn from(err: redis::RedisError) -> Self {
GitExecutorError::RedisError(err)
}
}
impl From<serde_json::Error> for GitExecutorError {
fn from(err: serde_json::Error) -> Self {
GitExecutorError::JsonError(err)
}
}
impl From<std::io::Error> for GitExecutorError {
fn from(err: std::io::Error) -> Self {
GitExecutorError::CommandExecutionError(err)
}
}
// Status enum for GitConfig
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub enum GitConfigStatus {
#[serde(rename = "error")]
Error,
#[serde(rename = "ok")]
Ok,
}
// Auth configuration for a specific git server
#[derive(Debug, Serialize, Deserialize)]
pub struct GitServerAuth {
pub sshagent: Option<bool>,
pub key: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
}
// Main configuration structure from Redis
#[derive(Debug, Serialize, Deserialize)]
pub struct GitConfig {
pub status: GitConfigStatus,
pub auth: HashMap<String, GitServerAuth>,
}
// GitExecutor struct
pub struct GitExecutor {
config: Option<GitConfig>,
}
impl GitExecutor {
// Create a new GitExecutor
pub fn new() -> Self {
GitExecutor { config: None }
}
// Initialize by loading configuration from Redis
pub fn init(&mut self) -> Result<(), GitExecutorError> {
// Try to load config from Redis
match self.load_config_from_redis() {
Ok(config) => {
self.config = Some(config);
Ok(())
}
Err(e) => {
// If Redis error, we'll proceed without config
// This is not a fatal error as we might use default git behavior
eprintln!("Warning: Failed to load git config from Redis: {}", e);
self.config = None;
Ok(())
}
}
}
// Load configuration from Redis
fn load_config_from_redis(&self) -> Result<GitConfig, GitExecutorError> {
// Create Redis command to get the herocontext:git key
let mut cmd = Cmd::new();
cmd.arg("GET").arg("herocontext:git");
// Execute the command
let result: redis::RedisResult<String> = execute_redis_command(&mut cmd);
match result {
Ok(json_str) => {
// Parse the JSON string into GitConfig
let config: GitConfig = serde_json::from_str(&json_str)?;
// Validate the config
if config.status == GitConfigStatus::Error {
return Err(GitExecutorError::InvalidAuthConfig(
"Config status is error".to_string(),
));
}
Ok(config)
}
Err(e) => Err(GitExecutorError::RedisError(e)),
}
}
// Check if SSH agent is loaded
fn is_ssh_agent_loaded(&self) -> bool {
let output = Command::new("ssh-add").arg("-l").output();
match output {
Ok(output) => output.status.success() && !output.stdout.is_empty(),
Err(_) => false,
}
}
// Get authentication configuration for a git URL
fn get_auth_for_url(&self, url: &str) -> Option<&GitServerAuth> {
if let Some(config) = &self.config {
let (server, _, _) = crate::parse_git_url(url);
if !server.is_empty() {
return config.auth.get(&server);
}
}
None
}
// Validate authentication configuration
fn validate_auth_config(&self, auth: &GitServerAuth) -> Result<(), GitExecutorError> {
// Rule: If sshagent is true, other fields should be empty
if let Some(true) = auth.sshagent {
if auth.key.is_some() || auth.username.is_some() || auth.password.is_some() {
return Err(GitExecutorError::InvalidAuthConfig(
"When sshagent is true, key, username, and password must be empty".to_string(),
));
}
// Check if SSH agent is actually loaded
if !self.is_ssh_agent_loaded() {
return Err(GitExecutorError::SshAgentNotLoaded);
}
}
// Rule: If key is set, other fields should be empty
if let Some(_) = &auth.key {
if auth.sshagent.unwrap_or(false) || auth.username.is_some() || auth.password.is_some()
{
return Err(GitExecutorError::InvalidAuthConfig(
"When key is set, sshagent, username, and password must be empty".to_string(),
));
}
}
// Rule: If username is set, password should be set and other fields empty
if let Some(_) = &auth.username {
if auth.sshagent.unwrap_or(false) || auth.key.is_some() {
return Err(GitExecutorError::InvalidAuthConfig(
"When username is set, sshagent and key must be empty".to_string(),
));
}
if auth.password.is_none() {
return Err(GitExecutorError::InvalidAuthConfig(
"When username is set, password must also be set".to_string(),
));
}
}
Ok(())
}
// Execute a git command with authentication
pub fn execute(&self, args: &[&str]) -> Result<Output, GitExecutorError> {
// Extract the git URL if this is a command that needs authentication
let url_arg = self.extract_git_url_from_args(args);
// If we have a URL and authentication config, use it
if let Some(url) = url_arg {
if let Some(auth) = self.get_auth_for_url(&url) {
// Validate the authentication configuration
self.validate_auth_config(auth)?;
// Execute with the appropriate authentication method
return self.execute_with_auth(args, auth);
}
}
// No special authentication needed, execute normally
self.execute_git_command(args)
}
// Extract git URL from command arguments
fn extract_git_url_from_args<'a>(&self, args: &[&'a str]) -> Option<&'a str> {
// Commands that might contain a git URL
if args.contains(&"clone")
|| args.contains(&"fetch")
|| args.contains(&"pull")
|| args.contains(&"push")
{
// The URL is typically the last argument for clone, or after remote for others
for (i, &arg) in args.iter().enumerate() {
if arg == "clone" && i + 1 < args.len() {
return Some(args[i + 1]);
}
if (arg == "fetch" || arg == "pull" || arg == "push") && i + 1 < args.len() {
// For these commands, the URL might be specified as a remote name
// We'd need more complex logic to resolve remote names to URLs
// For now, we'll just return None
return None;
}
}
}
None
}
// Execute git command with authentication
fn execute_with_auth(
&self,
args: &[&str],
auth: &GitServerAuth,
) -> Result<Output, GitExecutorError> {
// Handle different authentication methods
if let Some(true) = auth.sshagent {
// Use SSH agent (already validated that it's loaded)
self.execute_git_command(args)
} else if let Some(key) = &auth.key {
// Use SSH key
self.execute_with_ssh_key(args, key)
} else if let Some(username) = &auth.username {
// Use username/password
if let Some(password) = &auth.password {
self.execute_with_credentials(args, username, password)
} else {
// This should never happen due to validation
Err(GitExecutorError::AuthenticationError(
"Password is required when username is set".to_string(),
))
}
} else {
// No authentication method specified, use default
self.execute_git_command(args)
}
}
// Execute git command with SSH key
fn execute_with_ssh_key(&self, args: &[&str], key: &str) -> Result<Output, GitExecutorError> {
// Create a command with GIT_SSH_COMMAND to specify the key
let ssh_command = format!("ssh -i {} -o IdentitiesOnly=yes", key);
let mut command = Command::new("git");
command.env("GIT_SSH_COMMAND", ssh_command);
command.args(args);
let output = command.output()?;
if output.status.success() {
Ok(output)
} else {
let error = String::from_utf8_lossy(&output.stderr);
Err(GitExecutorError::GitCommandFailed(error.to_string()))
}
}
// Execute git command with username/password
fn execute_with_credentials(
&self,
args: &[&str],
username: &str,
password: &str,
) -> Result<Output, GitExecutorError> {
// For HTTPS authentication, we need to modify the URL to include credentials
// Create a new vector to hold our modified arguments
let modified_args: Vec<String> = args
.iter()
.map(|&arg| {
if arg.starts_with("https://") {
// Replace https:// with https://username:password@
format!("https://{}:{}@{}", username, password, &arg[8..]) // Skip the "https://" part
} else {
arg.to_string()
}
})
.collect();
// Execute the command
let mut command = Command::new("git");
// Add the modified arguments to the command
for arg in &modified_args {
command.arg(arg.as_str());
}
// Execute the command and handle the result
let output = command.output()?;
if output.status.success() {
Ok(output)
} else {
Err(GitExecutorError::GitCommandFailed(
String::from_utf8_lossy(&output.stderr).to_string(),
))
}
}
// Basic git command execution
fn execute_git_command(&self, args: &[&str]) -> Result<Output, GitExecutorError> {
let mut command = Command::new("git");
command.args(args);
let output = command.output()?;
if output.status.success() {
Ok(output)
} else {
let error = String::from_utf8_lossy(&output.stderr);
Err(GitExecutorError::GitCommandFailed(error.to_string()))
}
}
}
// Implement Default for GitExecutor
impl Default for GitExecutor {
fn default() -> Self {
Self::new()
}
}

6
git/src/lib.rs Normal file
View File

@@ -0,0 +1,6 @@
mod git;
mod git_executor;
pub mod rhai;
pub use git::*;
pub use git_executor::*;

183
git/src/rhai.rs Normal file
View File

@@ -0,0 +1,183 @@
//! Rhai wrappers for Git module functions
//!
//! This module provides Rhai wrappers for the functions in the Git module.
use crate::{GitError, GitRepo, GitTree};
use rhai::{Array, Dynamic, Engine, EvalAltResult};
/// Register Git module functions with the Rhai engine
///
/// # Arguments
///
/// * `engine` - The Rhai engine to register the functions with
///
/// # Returns
///
/// * `Result<(), Box<EvalAltResult>>` - Ok if registration was successful, Err otherwise
pub fn register_git_module(engine: &mut Engine) -> Result<(), Box<EvalAltResult>> {
// Register GitTree constructor
engine.register_type::<GitTree>();
engine.register_fn("git_tree_new", git_tree_new);
// Register GitTree methods
engine.register_fn("list", git_tree_list);
engine.register_fn("find", git_tree_find);
engine.register_fn("get", git_tree_get);
// Register GitRepo methods
engine.register_type::<GitRepo>();
engine.register_fn("path", git_repo_path);
engine.register_fn("has_changes", git_repo_has_changes);
engine.register_fn("pull", git_repo_pull);
engine.register_fn("reset", git_repo_reset);
engine.register_fn("commit", git_repo_commit);
engine.register_fn("push", git_repo_push);
// Register git_clone function for testing
engine.register_fn("git_clone", git_clone);
Ok(())
}
// Helper functions for error conversion
fn git_error_to_rhai_error<T>(result: Result<T, GitError>) -> Result<T, Box<EvalAltResult>> {
result.map_err(|e| {
Box::new(EvalAltResult::ErrorRuntime(
format!("Git error: {}", e).into(),
rhai::Position::NONE,
))
})
}
//
// GitTree Function Wrappers
//
/// Wrapper for GitTree::new
///
/// Creates a new GitTree with the specified base path.
pub fn git_tree_new(base_path: &str) -> Result<GitTree, Box<EvalAltResult>> {
git_error_to_rhai_error(GitTree::new(base_path))
}
/// Wrapper for GitTree::list
///
/// Lists all git repositories under the base path.
pub fn git_tree_list(git_tree: &mut GitTree) -> Result<Array, Box<EvalAltResult>> {
let repos = git_error_to_rhai_error(git_tree.list())?;
// Convert Vec<String> to Rhai Array
let mut array = Array::new();
for repo in repos {
array.push(Dynamic::from(repo));
}
Ok(array)
}
/// Wrapper for GitTree::find
///
/// Finds repositories matching a pattern and returns them as an array of GitRepo objects.
/// Assumes the underlying GitTree::find Rust method now returns Result<Vec<GitRepo>, GitError>.
pub fn git_tree_find(git_tree: &mut GitTree, pattern: &str) -> Result<Array, Box<EvalAltResult>> {
let repos: Vec<GitRepo> = git_error_to_rhai_error(git_tree.find(pattern))?;
// Convert Vec<GitRepo> to Rhai Array
let mut array = Array::new();
for repo in repos {
array.push(Dynamic::from(repo));
}
Ok(array)
}
/// Wrapper for GitTree::get
///
/// Gets a single GitRepo object based on an exact name or URL.
/// The underlying Rust GitTree::get method returns Result<Vec<GitRepo>, GitError>.
/// This wrapper ensures that for Rhai, 'get' returns a single GitRepo or an error
/// if zero or multiple repositories are found (for local names/patterns),
/// or if a URL operation fails or unexpectedly yields not exactly one result.
pub fn git_tree_get(
git_tree: &mut GitTree,
name_or_url: &str,
) -> Result<GitRepo, Box<EvalAltResult>> {
let mut repos_vec: Vec<GitRepo> = git_error_to_rhai_error(git_tree.get(name_or_url))?;
match repos_vec.len() {
1 => Ok(repos_vec.remove(0)), // Efficient for Vec of size 1, transfers ownership
0 => Err(Box::new(EvalAltResult::ErrorRuntime(
format!("Git error: Repository '{}' not found.", name_or_url).into(),
rhai::Position::NONE,
))),
_ => Err(Box::new(EvalAltResult::ErrorRuntime(
format!(
"Git error: Multiple repositories ({}) found matching '{}'. Use find() for patterns or provide a more specific name for get().",
repos_vec.len(),
name_or_url
)
.into(),
rhai::Position::NONE,
))),
}
}
//
// GitRepo Function Wrappers
//
/// Wrapper for GitRepo::path
///
/// Gets the path of the repository.
pub fn git_repo_path(git_repo: &mut GitRepo) -> String {
git_repo.path().to_string()
}
/// Wrapper for GitRepo::has_changes
///
/// Checks if the repository has uncommitted changes.
pub fn git_repo_has_changes(git_repo: &mut GitRepo) -> Result<bool, Box<EvalAltResult>> {
git_error_to_rhai_error(git_repo.has_changes())
}
/// Wrapper for GitRepo::pull
///
/// Pulls the latest changes from the remote repository.
pub fn git_repo_pull(git_repo: &mut GitRepo) -> Result<GitRepo, Box<EvalAltResult>> {
git_error_to_rhai_error(git_repo.pull())
}
/// Wrapper for GitRepo::reset
///
/// Resets any local changes in the repository.
pub fn git_repo_reset(git_repo: &mut GitRepo) -> Result<GitRepo, Box<EvalAltResult>> {
git_error_to_rhai_error(git_repo.reset())
}
/// Wrapper for GitRepo::commit
///
/// Commits changes in the repository.
pub fn git_repo_commit(
git_repo: &mut GitRepo,
message: &str,
) -> Result<GitRepo, Box<EvalAltResult>> {
git_error_to_rhai_error(git_repo.commit(message))
}
/// Wrapper for GitRepo::push
///
/// Pushes changes to the remote repository.
pub fn git_repo_push(git_repo: &mut GitRepo) -> Result<GitRepo, Box<EvalAltResult>> {
git_error_to_rhai_error(git_repo.push())
}
/// Dummy implementation of git_clone for testing
///
/// This function is used for testing the git module.
pub fn git_clone(url: &str) -> Result<(), Box<EvalAltResult>> {
// This is a dummy implementation that always fails with a Git error
Err(Box::new(EvalAltResult::ErrorRuntime(
format!("Git error: Failed to clone repository from URL: {}", url).into(),
rhai::Position::NONE,
)))
}

View File

@@ -0,0 +1,139 @@
use sal_git::*;
use std::collections::HashMap;
#[test]
fn test_git_executor_new() {
let executor = GitExecutor::new();
// We can't directly access the config field since it's private,
// but we can test that the executor was created successfully
let _executor = executor;
}
#[test]
fn test_git_executor_default() {
let executor = GitExecutor::default();
let _executor = executor;
}
#[test]
fn test_git_config_status_serialization() {
let status_ok = GitConfigStatus::Ok;
let status_error = GitConfigStatus::Error;
let json_ok = serde_json::to_string(&status_ok).unwrap();
let json_error = serde_json::to_string(&status_error).unwrap();
assert_eq!(json_ok, "\"ok\"");
assert_eq!(json_error, "\"error\"");
}
#[test]
fn test_git_config_status_deserialization() {
let status_ok: GitConfigStatus = serde_json::from_str("\"ok\"").unwrap();
let status_error: GitConfigStatus = serde_json::from_str("\"error\"").unwrap();
assert_eq!(status_ok, GitConfigStatus::Ok);
assert_eq!(status_error, GitConfigStatus::Error);
}
#[test]
fn test_git_server_auth_serialization() {
let auth = GitServerAuth {
sshagent: Some(true),
key: None,
username: None,
password: None,
};
let json = serde_json::to_string(&auth).unwrap();
assert!(json.contains("\"sshagent\":true"));
}
#[test]
fn test_git_server_auth_deserialization() {
let json = r#"{"sshagent":true,"key":null,"username":null,"password":null}"#;
let auth: GitServerAuth = serde_json::from_str(json).unwrap();
assert_eq!(auth.sshagent, Some(true));
assert_eq!(auth.key, None);
assert_eq!(auth.username, None);
assert_eq!(auth.password, None);
}
#[test]
fn test_git_config_serialization() {
let mut auth_map = HashMap::new();
auth_map.insert(
"github.com".to_string(),
GitServerAuth {
sshagent: Some(true),
key: None,
username: None,
password: None,
},
);
let config = GitConfig {
status: GitConfigStatus::Ok,
auth: auth_map,
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("\"status\":\"ok\""));
assert!(json.contains("\"github.com\""));
}
#[test]
fn test_git_config_deserialization() {
let json = r#"{"status":"ok","auth":{"github.com":{"sshagent":true,"key":null,"username":null,"password":null}}}"#;
let config: GitConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.status, GitConfigStatus::Ok);
assert!(config.auth.contains_key("github.com"));
assert_eq!(config.auth["github.com"].sshagent, Some(true));
}
#[test]
fn test_git_executor_error_display() {
let error = GitExecutorError::GitCommandFailed("command failed".to_string());
assert_eq!(format!("{}", error), "Git command failed: command failed");
let error = GitExecutorError::SshAgentNotLoaded;
assert_eq!(format!("{}", error), "SSH agent is not loaded");
let error = GitExecutorError::AuthenticationError("auth failed".to_string());
assert_eq!(format!("{}", error), "Authentication error: auth failed");
}
#[test]
fn test_git_executor_error_from_redis_error() {
let redis_error = redis::RedisError::from((redis::ErrorKind::TypeError, "type error"));
let git_error = GitExecutorError::from(redis_error);
match git_error {
GitExecutorError::RedisError(_) => {}
_ => panic!("Expected RedisError variant"),
}
}
#[test]
fn test_git_executor_error_from_serde_error() {
let serde_error = serde_json::from_str::<GitConfig>("invalid json").unwrap_err();
let git_error = GitExecutorError::from(serde_error);
match git_error {
GitExecutorError::JsonError(_) => {}
_ => panic!("Expected JsonError variant"),
}
}
#[test]
fn test_git_executor_error_from_io_error() {
let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let git_error = GitExecutorError::from(io_error);
match git_error {
GitExecutorError::CommandExecutionError(_) => {}
_ => panic!("Expected CommandExecutionError variant"),
}
}

119
git/tests/git_tests.rs Normal file
View File

@@ -0,0 +1,119 @@
use sal_git::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_parse_git_url_https() {
let (server, account, repo) = parse_git_url("https://github.com/user/repo.git");
assert_eq!(server, "github.com");
assert_eq!(account, "user");
assert_eq!(repo, "repo");
}
#[test]
fn test_parse_git_url_https_without_git_extension() {
let (server, account, repo) = parse_git_url("https://github.com/user/repo");
assert_eq!(server, "github.com");
assert_eq!(account, "user");
assert_eq!(repo, "repo");
}
#[test]
fn test_parse_git_url_ssh() {
let (server, account, repo) = parse_git_url("git@github.com:user/repo.git");
assert_eq!(server, "github.com");
assert_eq!(account, "user");
assert_eq!(repo, "repo");
}
#[test]
fn test_parse_git_url_ssh_without_git_extension() {
let (server, account, repo) = parse_git_url("git@github.com:user/repo");
assert_eq!(server, "github.com");
assert_eq!(account, "user");
assert_eq!(repo, "repo");
}
#[test]
fn test_parse_git_url_invalid() {
let (server, account, repo) = parse_git_url("invalid-url");
assert_eq!(server, "");
assert_eq!(account, "");
assert_eq!(repo, "");
}
#[test]
fn test_git_tree_new_creates_directory() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path().join("git_repos");
let base_path_str = base_path.to_str().unwrap();
let _git_tree = GitTree::new(base_path_str).unwrap();
assert!(base_path.exists());
assert!(base_path.is_dir());
}
#[test]
fn test_git_tree_new_existing_directory() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path().join("existing_dir");
fs::create_dir_all(&base_path).unwrap();
let base_path_str = base_path.to_str().unwrap();
let _git_tree = GitTree::new(base_path_str).unwrap();
}
#[test]
fn test_git_tree_new_invalid_path() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("file.txt");
fs::write(&file_path, "content").unwrap();
let file_path_str = file_path.to_str().unwrap();
let result = GitTree::new(file_path_str);
assert!(result.is_err());
if let Err(error) = result {
match error {
GitError::InvalidBasePath(_) => {}
_ => panic!("Expected InvalidBasePath error"),
}
}
}
#[test]
fn test_git_tree_list_empty_directory() {
let temp_dir = TempDir::new().unwrap();
let base_path_str = temp_dir.path().to_str().unwrap();
let git_tree = GitTree::new(base_path_str).unwrap();
let repos = git_tree.list().unwrap();
assert!(repos.is_empty());
}
#[test]
fn test_git_repo_new() {
let repo = GitRepo::new("/path/to/repo".to_string());
assert_eq!(repo.path(), "/path/to/repo");
}
#[test]
fn test_git_repo_clone() {
let repo1 = GitRepo::new("/path/to/repo".to_string());
let repo2 = repo1.clone();
assert_eq!(repo1.path(), repo2.path());
}
#[test]
fn test_git_error_display() {
let error = GitError::InvalidUrl("bad-url".to_string());
assert_eq!(format!("{}", error), "Could not parse git URL: bad-url");
let error = GitError::NoRepositoriesFound;
assert_eq!(format!("{}", error), "No repositories found");
let error = GitError::RepositoryNotFound("pattern".to_string());
assert_eq!(
format!("{}", error),
"No repositories found matching 'pattern'"
);
}

View File

@@ -0,0 +1,70 @@
// 01_git_basic.rhai
// Tests for basic Git functionality like creating a GitTree, listing repositories, finding repositories, and cloning repositories
// Custom assert function
fn assert_true(condition, message) {
if !condition {
print(`ASSERTION FAILED: ${message}`);
throw message;
}
}
// Create a temporary directory for Git operations
let test_dir = "rhai_test_git";
mkdir(test_dir);
print(`Created test directory: ${test_dir}`);
// Test GitTree constructor
print("Testing GitTree constructor...");
let git_tree = git_tree_new(test_dir);
print("✓ GitTree created successfully");
// Test GitTree.list() with empty directory
print("Testing GitTree.list() with empty directory...");
let repos = git_tree.list();
assert_true(repos.len() == 0, "Expected empty list of repositories");
print(`✓ GitTree.list(): Found ${repos.len()} repositories (expected 0)`);
// Test GitTree.find() with empty directory
print("Testing GitTree.find() with empty directory...");
let found_repos = git_tree.find("*");
assert_true(found_repos.len() == 0, "Expected empty list of repositories");
print(`✓ GitTree.find(): Found ${found_repos.len()} repositories (expected 0)`);
// Test GitTree.get() with a URL to clone a repository
// We'll use a small, public repository for testing
print("Testing GitTree.get() with URL...");
let repo_url = "https://github.com/rhaiscript/playground.git";
let repo = git_tree.get(repo_url);
print(`✓ GitTree.get(): Repository cloned successfully to ${repo.path()}`);
// Test GitRepo.path()
print("Testing GitRepo.path()...");
let repo_path = repo.path();
assert_true(repo_path.contains(test_dir), "Repository path should contain test directory");
print(`✓ GitRepo.path(): ${repo_path}`);
// Test GitRepo.has_changes()
print("Testing GitRepo.has_changes()...");
let has_changes = repo.has_changes();
print(`✓ GitRepo.has_changes(): ${has_changes}`);
// Test GitTree.list() after cloning
print("Testing GitTree.list() after cloning...");
let repos_after_clone = git_tree.list();
assert_true(repos_after_clone.len() > 0, "Expected non-empty list of repositories");
print(`✓ GitTree.list(): Found ${repos_after_clone.len()} repositories`);
// Test GitTree.find() after cloning
print("Testing GitTree.find() after cloning...");
let found_repos_after_clone = git_tree.find("*");
assert_true(found_repos_after_clone.len() > 0, "Expected non-empty list of repositories");
print(`✓ GitTree.find(): Found ${found_repos_after_clone.len()} repositories`);
// Clean up
print("Cleaning up...");
delete(test_dir);
assert_true(!exist(test_dir), "Directory deletion failed");
print(`✓ Cleanup: Directory ${test_dir} removed`);
print("All basic Git tests completed successfully!");

View File

@@ -0,0 +1,61 @@
// 02_git_operations.rhai
// Tests for Git operations like pull, reset, commit, and push
// Custom assert function
fn assert_true(condition, message) {
if !condition {
print(`ASSERTION FAILED: ${message}`);
throw message;
}
}
// Create a temporary directory for Git operations
let test_dir = "rhai_test_git_ops";
mkdir(test_dir);
print(`Created test directory: ${test_dir}`);
// Create a GitTree
print("Creating GitTree...");
let git_tree = git_tree_new(test_dir);
print("✓ GitTree created successfully");
// Clone a repository
print("Cloning repository...");
let repo_url = "https://github.com/rhaiscript/playground.git";
let repo = git_tree.get(repo_url);
print(`✓ Repository cloned successfully to ${repo.path()}`);
// Test GitRepo.pull()
print("Testing GitRepo.pull()...");
try {
let pulled_repo = repo.pull();
print("✓ GitRepo.pull(): Pull operation completed successfully");
} catch(err) {
// Pull might fail if there are no changes or network issues
print(`Note: GitRepo.pull() failed (expected): ${err}`);
print("✓ GitRepo.pull(): Method exists and can be called");
}
// Test GitRepo.reset()
print("Testing GitRepo.reset()...");
try {
let reset_repo = repo.reset();
print("✓ GitRepo.reset(): Reset operation completed successfully");
} catch(err) {
print(`Error in GitRepo.reset(): ${err}`);
throw err;
}
// Note: We won't test commit and push as they would modify the remote repository
// Instead, we'll just verify that the methods exist and can be called
print("Note: Not testing commit and push to avoid modifying remote repositories");
print("✓ GitRepo.commit() and GitRepo.push() methods exist");
// Clean up
print("Cleaning up...");
delete(test_dir);
assert_true(!exist(test_dir), "Directory deletion failed");
print(`✓ Cleanup: Directory ${test_dir} removed`);
print("All Git operations tests completed successfully!");

View File

@@ -0,0 +1,129 @@
// run_all_tests.rhai
// Test runner for all Git module tests
// Custom assert function
fn assert_true(condition, message) {
if !condition {
print(`ASSERTION FAILED: ${message}`);
throw message;
}
}
// Test counters
let passed = 0;
let failed = 0;
print("=== Git Module Test Suite ===");
print("Running comprehensive tests for Git module functionality...");
// Test 1: Basic Git Operations
print("\n--- Running Basic Git Operations Tests ---");
try {
// Create a temporary directory for Git operations
let test_dir = "rhai_test_git";
mkdir(test_dir);
print(`Created test directory: ${test_dir}`);
// Test GitTree constructor
print("Testing GitTree constructor...");
let git_tree = git_tree_new(test_dir);
print("✓ GitTree created successfully");
// Test GitTree.list() with empty directory
print("Testing GitTree.list() with empty directory...");
let repos = git_tree.list();
assert_true(repos.len() == 0, "Expected empty list of repositories");
print(`✓ GitTree.list(): Found ${repos.len()} repositories (expected 0)`);
// Test GitTree.find() with empty directory
print("Testing GitTree.find() with empty directory...");
let found_repos = git_tree.find("*");
assert_true(found_repos.len() == 0, "Expected empty list of repositories");
print(`✓ GitTree.find(): Found ${found_repos.len()} repositories (expected 0)`);
// Clean up
print("Cleaning up...");
delete(test_dir);
assert_true(!exist(test_dir), "Directory deletion failed");
print(`✓ Cleanup: Directory ${test_dir} removed`);
print("--- Basic Git Operations Tests completed successfully ---");
passed += 1;
} catch(err) {
print(`!!! Error in Basic Git Operations Tests: ${err}`);
failed += 1;
}
// Test 2: Git Repository Operations
print("\n--- Running Git Repository Operations Tests ---");
try {
// Create a temporary directory for Git operations
let test_dir = "rhai_test_git_ops";
mkdir(test_dir);
print(`Created test directory: ${test_dir}`);
// Create a GitTree
print("Creating GitTree...");
let git_tree = git_tree_new(test_dir);
print("✓ GitTree created successfully");
// Clean up
print("Cleaning up...");
delete(test_dir);
assert_true(!exist(test_dir), "Directory deletion failed");
print(`✓ Cleanup: Directory ${test_dir} removed`);
print("--- Git Repository Operations Tests completed successfully ---");
passed += 1;
} catch(err) {
print(`!!! Error in Git Repository Operations Tests: ${err}`);
failed += 1;
}
// Test 3: Git Error Handling
print("\n--- Running Git Error Handling Tests ---");
try {
print("Testing git_clone with invalid URL...");
try {
git_clone("invalid-url");
print("!!! Expected error but got success");
failed += 1;
} catch(err) {
assert_true(err.contains("Git error"), "Expected Git error message");
print("✓ git_clone properly handles invalid URLs");
}
print("Testing GitTree with invalid path...");
try {
let git_tree = git_tree_new("/invalid/nonexistent/path");
print("Note: GitTree creation succeeded (directory was created)");
// Clean up if it was created
try {
delete("/invalid");
} catch(cleanup_err) {
// Ignore cleanup errors
}
} catch(err) {
print(`✓ GitTree properly handles invalid paths: ${err}`);
}
print("--- Git Error Handling Tests completed successfully ---");
passed += 1;
} catch(err) {
print(`!!! Error in Git Error Handling Tests: ${err}`);
failed += 1;
}
// Summary
print("\n=== Test Results ===");
print(`Passed: ${passed}`);
print(`Failed: ${failed}`);
print(`Total: ${passed + failed}`);
if failed == 0 {
print("🎉 All tests passed!");
} else {
print("❌ Some tests failed!");
}
print("=== Git Module Test Suite Complete ===");

52
git/tests/rhai_tests.rs Normal file
View File

@@ -0,0 +1,52 @@
use sal_git::rhai::*;
use rhai::Engine;
#[test]
fn test_register_git_module() {
let mut engine = Engine::new();
let result = register_git_module(&mut engine);
assert!(result.is_ok());
}
#[test]
fn test_git_tree_new_function_registered() {
let mut engine = Engine::new();
register_git_module(&mut engine).unwrap();
// Test that the function is registered by trying to call it
// This will fail because /nonexistent doesn't exist, but it proves the function is registered
let result = engine.eval::<String>(r#"
let result = "";
try {
let git_tree = git_tree_new("/nonexistent");
result = "success";
} catch(e) {
result = "error_caught";
}
result
"#);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "error_caught");
}
#[test]
fn test_git_clone_function_registered() {
let mut engine = Engine::new();
register_git_module(&mut engine).unwrap();
// Test that git_clone function is registered and returns an error as expected
let result = engine.eval::<String>(r#"
let result = "";
try {
git_clone("https://example.com/repo.git");
result = "unexpected_success";
} catch(e) {
result = "error_caught";
}
result
"#);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "error_caught");
}