pub mod commands;
pub mod git;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use thiserror::Error;
use tracing::{debug, info};
fn format_command_error(
backend: &VcsBackend,
message: &str,
command: &Option<String>,
working_dir: &Option<PathBuf>,
stderr: &Option<String>,
stdout: &Option<String>,
) -> String {
let mut parts = vec![format!("{} command failed: {}", backend, message)];
if let Some(cmd) = command {
parts.push(format!("command: {}", cmd));
}
if let Some(dir) = working_dir {
parts.push(format!("working_dir: {}", dir.display()));
}
if let Some(err) = stderr {
if !err.is_empty() {
parts.push(format!("stderr: {}", err));
}
}
if let Some(out) = stdout {
if !out.is_empty() {
parts.push(format!("stdout: {}", out));
}
}
parts.join("; ")
}
#[derive(Error, Debug)]
pub enum VcsError {
#[error("{}", format_command_error(.backend, .message, .command, .working_dir, .stderr, .stdout))]
Command {
backend: VcsBackend,
message: String,
command: Option<String>,
working_dir: Option<PathBuf>,
stderr: Option<String>,
stdout: Option<String>,
},
#[error("Merge conflict in {backend}: {details}")]
Conflict {
backend: VcsBackend,
details: String,
},
#[error("{backend} not available: {reason}")]
#[allow(dead_code)] NotAvailable { backend: VcsBackend, reason: String },
#[error("Uncommitted changes detected: {0}")]
#[allow(dead_code)]
UncommittedChanges(String),
#[error("No VCS backend available for parallel execution")]
NoBackend,
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
impl VcsError {
pub fn git_command(message: impl Into<String>) -> Self {
VcsError::Command {
backend: VcsBackend::Git,
message: message.into(),
command: None,
working_dir: None,
stderr: None,
stdout: None,
}
}
#[allow(dead_code)]
pub fn git_command_with_context(
message: impl Into<String>,
command: Option<String>,
working_dir: Option<PathBuf>,
stderr: Option<String>,
stdout: Option<String>,
) -> Self {
VcsError::Command {
backend: VcsBackend::Git,
message: message.into(),
command,
working_dir,
stderr,
stdout,
}
}
pub fn git_conflict(details: impl Into<String>) -> Self {
VcsError::Conflict {
backend: VcsBackend::Git,
details: details.into(),
}
}
}
pub type VcsResult<T> = std::result::Result<T, VcsError>;
#[derive(Debug, Clone)]
pub struct VcsWarning {
pub title: String,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum VcsBackend {
#[default]
Auto,
Git,
}
impl std::fmt::Display for VcsBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VcsBackend::Auto => write!(f, "auto"),
VcsBackend::Git => write!(f, "git"),
}
}
}
impl std::str::FromStr for VcsBackend {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"auto" => Ok(VcsBackend::Auto),
"git" => Ok(VcsBackend::Git),
_ => Err(format!(
"Invalid VCS backend: {}. Valid values: auto, git",
s
)),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)] pub enum WorkspaceStatus {
Created,
Applying,
Applied(String),
Accepting,
Rejecting,
Archiving,
Resolving,
MergeWait,
Failed(String),
Merged,
Cleaned,
}
impl WorkspaceStatus {
pub fn is_active(&self) -> bool {
match self {
WorkspaceStatus::Created => true,
WorkspaceStatus::Applying => true,
WorkspaceStatus::Applied(_) => true,
WorkspaceStatus::Accepting => true,
WorkspaceStatus::Rejecting => true,
WorkspaceStatus::Archiving => true,
WorkspaceStatus::Resolving => true,
WorkspaceStatus::MergeWait => false,
WorkspaceStatus::Failed(_) => false,
WorkspaceStatus::Merged => false,
WorkspaceStatus::Cleaned => false,
}
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub struct Workspace {
pub name: String,
pub path: PathBuf,
pub change_id: String,
pub base_revision: String,
pub status: WorkspaceStatus,
}
#[derive(Debug, Clone)]
pub struct WorkspaceInfo {
pub path: PathBuf,
pub change_id: String,
pub workspace_name: String,
pub last_modified: SystemTime,
}
#[async_trait]
#[allow(dead_code)] pub trait WorkspaceManager: Send + Sync {
fn backend_type(&self) -> VcsBackend;
async fn check_available(&self) -> VcsResult<bool>;
async fn prepare_for_parallel(&self) -> VcsResult<Option<VcsWarning>>;
async fn get_current_revision(&self) -> VcsResult<String>;
async fn create_workspace(
&mut self,
change_id: &str,
base_revision: Option<&str>,
) -> VcsResult<Workspace>;
fn update_workspace_status(&mut self, workspace_name: &str, status: WorkspaceStatus);
async fn merge_workspaces(&self, revisions: &[String]) -> VcsResult<String>;
async fn cleanup_workspace(&mut self, workspace_name: &str) -> VcsResult<()>;
async fn cleanup_all(&mut self) -> VcsResult<()>;
fn max_concurrent(&self) -> usize;
fn workspaces(&self) -> Vec<Workspace>;
fn active_workspace_count(&self) -> usize {
self.workspaces()
.iter()
.filter(|w| w.status.is_active())
.count()
}
async fn list_worktree_change_ids(&self) -> VcsResult<HashSet<String>>;
fn conflict_resolution_prompt(&self) -> &'static str;
async fn snapshot_working_copy(&self, workspace_path: &Path) -> VcsResult<()>;
async fn set_commit_message(&self, workspace_path: &Path, message: &str) -> VcsResult<()>;
async fn create_iteration_snapshot(
&self,
workspace_path: &Path,
change_id: &str,
iteration: u32,
completed: u32,
total: u32,
) -> VcsResult<()>;
async fn squash_wip_commits(
&self,
workspace_path: &Path,
change_id: &str,
final_iteration: u32,
) -> VcsResult<()>;
async fn get_revision_in_workspace(&self, workspace_path: &Path) -> VcsResult<String>;
async fn get_status(&self) -> VcsResult<String>;
async fn get_log_for_revisions(&self, revisions: &[String]) -> VcsResult<String>;
async fn detect_conflicts(&self) -> VcsResult<Vec<String>>;
fn forget_workspace_sync(&self, workspace_name: &str);
fn repo_root(&self) -> &Path;
async fn ensure_original_branch_initialized(&self) -> VcsResult<String>;
fn original_branch(&self) -> Option<String>;
async fn find_existing_workspace(
&mut self,
change_id: &str,
) -> VcsResult<Option<WorkspaceInfo>>;
async fn reuse_workspace(&mut self, workspace_info: &WorkspaceInfo) -> VcsResult<Workspace>;
}
#[allow(dead_code)] pub async fn detect_vcs_backend<P: AsRef<Path>>(
requested: VcsBackend,
cwd: P,
) -> VcsResult<VcsBackend> {
let cwd = cwd.as_ref();
match requested {
VcsBackend::Git => {
if git::commands::check_git_repo(cwd).await? {
info!("Using explicitly requested Git backend");
Ok(VcsBackend::Git)
} else {
Err(VcsError::NoBackend)
}
}
VcsBackend::Auto => {
debug!("Auto-detecting VCS backend...");
if git::commands::check_git_repo(cwd).await? {
info!("Auto-detected Git backend");
Ok(VcsBackend::Git)
} else {
Err(VcsError::NoBackend)
}
}
}
}
pub use git::GitWorkspaceManager;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vcs_backend_from_str() {
assert_eq!("auto".parse::<VcsBackend>().unwrap(), VcsBackend::Auto);
assert_eq!("git".parse::<VcsBackend>().unwrap(), VcsBackend::Git);
assert_eq!("Git".parse::<VcsBackend>().unwrap(), VcsBackend::Git);
assert!("invalid".parse::<VcsBackend>().is_err());
}
#[test]
fn test_vcs_backend_display() {
assert_eq!(VcsBackend::Auto.to_string(), "auto");
assert_eq!(VcsBackend::Git.to_string(), "git");
}
#[test]
fn test_workspace_status_equality() {
assert_eq!(WorkspaceStatus::Created, WorkspaceStatus::Created);
assert_ne!(WorkspaceStatus::Created, WorkspaceStatus::Applying);
assert_eq!(
WorkspaceStatus::Applied("rev1".to_string()),
WorkspaceStatus::Applied("rev1".to_string())
);
}
#[test]
fn test_vcs_error_constructors() {
let err = VcsError::git_command("test error");
assert!(matches!(
err,
VcsError::Command {
backend: VcsBackend::Git,
..
}
));
let err = VcsError::git_conflict("conflict details");
assert!(matches!(
err,
VcsError::Conflict {
backend: VcsBackend::Git,
..
}
));
}
#[test]
fn test_vcs_backend_default_is_auto() {
let backend: VcsBackend = Default::default();
assert_eq!(backend, VcsBackend::Auto);
}
#[test]
fn test_vcs_backend_serialization() {
let backend = VcsBackend::Git;
let json = serde_json::to_string(&backend).unwrap();
assert_eq!(json, "\"git\"");
let backend = VcsBackend::Auto;
let json = serde_json::to_string(&backend).unwrap();
assert_eq!(json, "\"auto\"");
}
#[test]
fn test_vcs_backend_deserialization() {
let git: VcsBackend = serde_json::from_str("\"git\"").unwrap();
assert_eq!(git, VcsBackend::Git);
let auto: VcsBackend = serde_json::from_str("\"auto\"").unwrap();
assert_eq!(auto, VcsBackend::Auto);
}
#[test]
fn test_workspace_status_lifecycle() {
let status = WorkspaceStatus::Created;
assert_eq!(status, WorkspaceStatus::Created);
let status = WorkspaceStatus::Applying;
assert_eq!(status, WorkspaceStatus::Applying);
let status = WorkspaceStatus::Applied("abc123".to_string());
assert!(matches!(status, WorkspaceStatus::Applied(ref s) if s == "abc123"));
let status = WorkspaceStatus::Merged;
assert_eq!(status, WorkspaceStatus::Merged);
let status = WorkspaceStatus::Cleaned;
assert_eq!(status, WorkspaceStatus::Cleaned);
}
#[test]
fn test_workspace_status_failed_includes_message() {
let status = WorkspaceStatus::Failed("LLM timeout".to_string());
assert!(matches!(status, WorkspaceStatus::Failed(ref msg) if msg == "LLM timeout"));
}
#[test]
fn test_workspace_status_is_active() {
assert!(WorkspaceStatus::Created.is_active());
assert!(WorkspaceStatus::Applying.is_active());
assert!(WorkspaceStatus::Applied("abc123".to_string()).is_active());
assert!(WorkspaceStatus::Accepting.is_active());
assert!(WorkspaceStatus::Archiving.is_active());
assert!(WorkspaceStatus::Resolving.is_active());
assert!(!WorkspaceStatus::MergeWait.is_active());
assert!(!WorkspaceStatus::Failed("error".to_string()).is_active());
assert!(!WorkspaceStatus::Merged.is_active());
assert!(!WorkspaceStatus::Cleaned.is_active());
}
#[test]
fn test_vcs_error_uncommitted_changes() {
let err = VcsError::UncommittedChanges("staged files exist".to_string());
let msg = format!("{}", err);
assert!(msg.contains("staged files exist"));
}
#[test]
fn test_vcs_error_no_backend() {
let err = VcsError::NoBackend;
let msg = format!("{}", err);
assert!(msg.contains("No VCS backend"));
}
#[test]
fn test_vcs_error_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err: VcsError = io_err.into();
assert!(matches!(err, VcsError::Io(_)));
}
#[test]
fn test_workspace_creation() {
let ws = Workspace {
name: "ws-add-feature-12345".to_string(),
path: std::path::PathBuf::from("/tmp/workspaces/ws-add-feature-12345"),
change_id: "add-feature".to_string(),
base_revision: "abc123def456".to_string(),
status: WorkspaceStatus::Created,
};
assert_eq!(ws.name, "ws-add-feature-12345");
assert_eq!(ws.change_id, "add-feature");
assert!(ws.path.to_str().unwrap().contains("ws-add-feature"));
}
#[test]
fn test_workspace_name_sanitization_pattern() {
let change_id = "feature/add-login";
let sanitized = format!("ws-{}-12345", change_id.replace(['/', '\\', ' '], "-"));
assert_eq!(sanitized, "ws-feature-add-login-12345");
assert!(!sanitized.contains('/'));
assert!(!sanitized.contains('\\'));
}
}